如何只用Swift写一款句子📖阅读App

为了更好的阅读体验,请参见我的博客原文 Weslie's Bolg

前言

关于我之前的作品「Simple Reader」,也就是『简·阅』,最初诞生的时候大概还是在2017 年了,现在打开iCloud上Sketch的设计初稿还能看到是2017年十月创建的,到现在刚好两年整了。最近闲着无聊,上个月App完善的也差不多了,重新进行了设计并且发布了全新的版本,于是顺便想写一篇文章记录一下他背后的故事,也算是给好多对Simple感兴趣的还有关注我的小朋友一个交待吧🤪

这篇文章会讲述Simple整个App从诞生到现在所发生的一切,包括UI的设计,客户端的开发,后端服务器的搭建,宣传等等。如果你对代码无感,也可以阅读完UI部分直接看后记。

PRD

一个App的诞生之初,当然是有明确的需求了,需求文档📝(Product Requirements Document)就是最开始的部分,上面会列出它所有的功能,以及大概的逻辑,用到的技术,又或者是开发估时。总之PRD记录了整个App的详细信息,这里可以预览『简·阅』的需求文档

根据需求文档,就到设计UI初稿了,我是用的Sketch进行绘制UI,然后导入到Flinto继续设计交互原型。

UI

起源

在讲设计之前,首先来谈谈我的最初的构想吧。2017年,大概在暑假的时候,当时在隔壁老王的公司混吃混喝,也学习到了很多技能,主要就是iOS开发,当然也要用时感谢一路陪伴我的所有人,没有你们,就没有这今天的一切。后来发现自己既然已经写过能上架的App了(当然是为别人而写的)为何不自己创造一款,从最开始的什么都没有,到实现所有的一切,真正意义上完全属于自己的一款App,于是故事就开始了。

最初想了很久,后来偶然间想起了曾经高中的时候喜欢收集一些句子,一些读起来很美并且有意境的句子,抑或是那些比较偏文艺风格的。于是便萌生出来打造一款类似阅读类App的想法,当然他一定要极简,遵循Less is More的设计原则。当有了这个想法之后,就开始尝试构思所谓的需求求文档。

曾经我有学过一点UI设计,最早应该也是高二的时候了,当时有了自己的第一台MacBook Pro,整天想拿它来干点什么,于是在暑假的时候就用Sketch临摹了一些UI,顺便也学会了怎么使用这些工具。当时高中还梦想着以后能当一名设计师或者是程序员,现在选择了后者,设计仍然是个爱好。说回Sketch,虽然不是专业的设计师,最起码还是个业余的,其实只要会用就行,工具并不那么重要。

最初的设计参考了几款非常喜欢的App,有一款曾经叫「日课」,现在名字叫做「岛读」,也是一款极简风格的App,他每天会给你推送一篇文章,你可以收藏或者评论。再后来接触到了他们的开发者——同时也开发了我最爱的开发「潮汐」🌊的Moreless团队,「潮汐」是一款专门听白噪声的专注类App,对他可是一见钟情,后来有机会和开发团队的大佬聊了聊我想做的Simple,大佬给了我一些建议,我也非常受启发,于是就有了很多的点子可以给Simple润色一下。后来又参考了众多类似的App,也总结了很多,到最后再继续完善了一下需求文档,修改了UI的设计稿,最初版本的Simple于是就诞生了……

设计稿

这里是Simple最初版本的UI设计稿,下面是菜单和日历等页面

simple-v1-sketch-pencil-page1
simple-v1-sketch-pencil-page2

当完成草图之后,就开始用Sketch进行真正的UI绘制了,所有的页面都会有精确的细节,例如间距和控件的大小等等。但是有一些页面因为实现的可能比较繁琐的原因,后来在Sketch设计稿中有所取舍

simple-v1-sketch

交互原型

当然还有交互原型设计稿,完成UI设计款之后,就开始准备交互原型,一个用来展示App的操作逻辑,转场的动画等等的可以交互体验的设计稿。使用Flinto直接从Sketch中导入文件,这是设计完成的交互原型

simple-v1-flinto-ux-prototype

当然它是可以实时预览与操作的,这只是用来演示的,白没有实际的功能。事实证明上下滑动句子跟着动这个功能实现起来可能比较困难,于是就在开发中直接废弃掉了。

[图片上传失败...(image-f5e5ca-1573023475334)]

演变

后来因为获得了苹果奖学金,去了趟WWDC,在现场跟苹果设计师交流了一下,得到了一些Simple的改进建议,当时真的超级兴奋😆于是在今年暑假的时候把这些UI上的改进实现了一下,所以才诞生了现在的Simple2.0版本

这是当时的草图笔记,主要有几个方面的改进

  • 卡片视图替代全屏幕滑动
  • 可供选择多种背景(例如纸张的纹理效果)
  • 更流畅的细节动画
  • 交互方面细节的提升
ui-design-sketch-wwdc

就此,结合了很多用户的反馈,在添加了一些新功能之后,这是最终的UI设计稿

simple-v2-sketch

原来的左上角的关闭按钮会在新版本的系统中随机出现bug,于是优化了动画效果(左上角的菜单按钮会在点击评论的时候变成一个X)

最初的版本有用Flinto设计交互原型,但是2.0却没有,这也是我觉得可能最失败的一个地方,直接导致了后边的代码想到哪里写到哪里,没有具体的头绪,例如转场的动画之类的,也没有一个明确的目的不知道实现一个怎样的效果,所以总结一下这个失败的教训。

[图片上传失败...(image-533ddb-1573023475334)]

客户端

UI和交互设计原型都几乎差不多了,现在就开始正式的开发吧!
后端的选择和App的登陆逻辑由于后面有非常大的改动,所以打算把它从整体的逻辑分开来单独讲。

最初版本的架构和下面的有所不同,因为手机验证码登陆的原因,真的是各种槽点,幸亏今年WWDC19出了Sign in with Apple,以后要用的登陆会方便很多,不需要用再用冗余或者有一些“流氓”的国内某些SDK。总之,登陆功能最开始是基于LeanCloud的,后面被诟病太多,所以干脆去掉了,换成了用iCloud构建用户实体。后来发现使用iCloud创建一个简单的用户实体还是很好操作管理的。

主体架构

我们首先来看一下整体的架构吧,总体还是MVC,没有什么复杂的地方

image

)

请求得到数据后,就使用CoreData进行持久化存储,Favorite和Calendar的数据源都来自CoreData,主页的话则是Quote Cell在发生更改后在didSet里更新其他的东西

主页面的实现

Simple的主页面是一个可以一直滚动的阅读页面,原来MainQuoteCell部分,不是用的CollectionView,而是基于ScrollView+Pagin,最初的版本长成这个样子:
[图片上传失败...(image-d75eda-1573023475334)]

当然后面的实践和用户各种各样的反馈证明了这样做体验确实会比较差,但是还是来看一看以上是怎么实现的吧……

view hierarhy old

既然是一个可以滚动的视图,那么会考虑一个ScrollView,而且基于Paging。屏幕上的一整块主体内容作为一个subview,我们叫它QuoteView,然后左边中间右边一共有三个QuoteView实例,再把这三个添加到ScrollView上面,通过ContentView的偏移来展示不停地滑动。viewDidLoad的时候,从数据源quoteArray会取出最后三个Quote,分别赋给这三个QuoteView中的负责解析并设置UI的viewModel

  • 每当往左边一个view滑动,也就是滑动到昨天的句子,一并更新三个QuoteViewviewModel,把中间一个设置成昨天的句子,左边的设置成前天的
  • 滑动到右边一块的时候同理
  • 临界情况,当滑动到最左边或最右边的时候,根据判断quote在array中的index来得知是否在边界
  • 如果在右边界,也就是最后一个句子,当然现在还是三个view,把最右边的viewModel也使用今天的句子(防止意外的隐式解包崩溃),并在scrollViewDidScrollscrollViewDidEndDecelerating的时候禁用它往右边一个句子滑动。左边界同理
  • quoteArray.count<=2的时候,同上的逻辑,不需要进行什么修改

但是总而言之,上面的逻辑可能看上去问题不是很多,但是漏洞却还是很多的,比如说:

在一开始没有数据,quoteArray还没有被赋值的时候,直接滑动会导致强制解包nil崩溃。这也是我当初一直没有修复的bug,他会在第一次安装,网络又比较慢的时候会出现,因为后来有在其中加了CoreData持久化存储所有的句子。

另外,基于PagingScrollView和使用scrollViewDidEndDecelerating之类的协议方法来处理滑动后的事件,直接导致了滑动的交互体验非常差,因为必须要在结束减速的时候进行viewModel重新赋值,所以有一种卡顿的感觉。由于不可忍受的交互体验差,后来在新的版本中换成了CollectionView,也就是下面要继续讲的。

以下是具体的代码:

/// Setup initial scroll view content size and first pages once
lazy var setupInitialPages: Void = {
    let leftView = Bundle.main.loadNibNamed("QuoteView", owner: nil, options: nil)?.first as! QuoteView
    let middleView = Bundle.main.loadNibNamed("QuoteView", owner: nil, options: nil)?.first as! QuoteView
    let rightView = Bundle.main.loadNibNamed("QuoteView", owner: nil, options: nil)?.first as! QuoteView
    let scrollViewHeight = scrollView.frame.height

    for (i, view) in [leftView, middleView, rightView].enumerated() {
        view.frame = CGRect(x: screenWidth * CGFloat(i), y: 0, width: screenWidth, height: scrollViewHeight)
    }

    [leftView, middleView, rightView].forEach { scrollView.addSubview($0) }
    scrollView.contentSize = CGSize(width: screenWidth * 3, height: scrollView.frame.height)
    scrollView.setContentOffset(CGPoint(x: screenWidth, y: 0), animated: false)
}()

以上只会在第一次开启App的时候调用,也就相当于GCDOnce TokenSwift给了我们一种更简洁的方式让代码只调用一次

override func viewDidAppear(_ animated: Bool) {
    /**
     Setup the initial scroll view content size and first pages only once.
     (Due to this function called each time views are added or removed).
     */
    _ = setupInitialPages
}

当然以上的逻辑仔细琢磨,依然有很多潜在的问题或是错误,因此没有必要过于纠结,实践已经证明了在这种场景像使用ScrollView不是一种好的选择。不论从逻辑或代码精简的角度,都不能达到一个较好的预期,于是下面是Simple2.0的实现方法,直接使用CollectionView。至于下面的三个按钮,最开始每个view自带,当然后来在很多朋友,还有苹果的设计师建议下,改成了只有句子卡片可以滑动。

view hierarchy

还有主页的CollectionViewCell,点击展开和收缩动画均使用AutoLayout进行,也算是比较简单的一种方式了吧。

main page

当然使用一个CollectionView就变得更加简洁,改动后的逻辑变得异常的简单,只需要处理好CollectionView的Layout即可。

extension MainViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let cell = collectionView.cellForItem(at: indexPath) as! QuoteCollectionViewCell
        // select item animation
        if isQuoteCardExpanded {
            cell.shrink()
            UIView.animate(withDuration: 0.5) {
                self.bottomBarItemsView.alpha = 0
            }
        } else {
            cell.expand()
            UIView.animate(withDuration: 0.5) {
                self.bottomBarItemsView.alpha = 1
            }
        }
        
        isQuoteCardExpanded = !isQuoteCardExpanded
    }
    
    func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        // current cell on the screen
        if let currentCell = quotesCollectionView.visibleCells.first, let index = quotesCollectionView.indexPath(for: currentCell)?.row {
            // update Model
            currentQuote = quotes[index]
            // update likeButton status
            if let qid = currentQuote?.id, favIdArray.contains(qid) {
                likeButton.isUserFav = true
            } else {
                likeButton.isUserFav = false
            }
        }
    }
}

extension MainViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return quotes.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "QuoteCollectionViewCell", for: indexPath) as! QuoteCollectionViewCell
        
        cell.viewModel = quotes[indexPath.row]
        return cell
    }
}

extension MainViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 0
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: UIScreen.main.bounds.width, height: collectionView.frame.height)
    }
}

菜单详情页面则全部使用Storyboard构建

detail

收藏列表和日历列表的Cell,数据源均来自CoreData。收藏列表则是通过iCloud获取句子的id数组,然后进行筛选后展示,其他例如评论的部分则是动态请求网络,没有做过多的持久化存储。

那么下面我们就来看看Simple里的比较新奇的内容,网上应该很少会有资料讲到的,当然也包括一些新的技术,以及个人的见解等

关于适配DarkMode

在WWDC19里,iOS13支持了DarkMode,苹果官方的文档讲的还算比较详细,但是强烈建议看一下session 214讲如何实现它,大概一句话总结就是

在App里View的层级结构树上,使用Dark Appearance的View将会作用于当前View以及他所有的子View

session 214的PDF上关于Dark Mode的讲解很好的诠释了这一点

214_implementing_dark_mode_on_ios (dragged)

于是我的解决方案是获取当前的Window,当然也可以参考StackOverflow上的How to resolve: 'keyWindow' was deprecated in iOS 13.0,不能直接通过UIApplication.shared.keyWindow获取是因为window的层级在iOS13上做出了更改,keyWindow已经被deprecated了。

/// current keyWindow
var currentWindow: UIWindow? = {
    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let keyWindow = windowScene.windows.first {
        return keyWindow
    } else {
        return nil
    }
}()

获取到window后就可以通过更改它的overrideUserInterfaceStyle来达到更改整个App浅色深色样式的目的

currentWindow?.overrideUserInterfaceStyle = .light
currentWindow?.overrideUserInterfaceStyle = .dark
currentWindow?.overrideUserInterfaceStyle = .unspecified

为了能够在App启动的时候就能更改样式,我们需要进行两个操作

1.在设置完currentWindowoverrideUserInterfaceStyle之后,使用UserDefaults存储对应的style,由于UIUserInterfaceStyle是一个Int类型的枚举

@available(iOS 12.0, *)
public enum UIUserInterfaceStyle : Int {
    case unspecified = 0
    case light = 1
    case dark = 2
}

我们可以通过UserDefaults来存储当前的UIUserInterfaceStyle

UserDefaults.standard.set(UIUserInterfaceStyle.dark.rawValue, forKey: "com.weslie.Simple.AppearanceKey")

2.当设置完成之后,就可以在下一次App启动的时候,通过在AppDelegatedidFinishLaunchingWithOptions中加入这么一段,这样就可以恢复原来的样式设置

currentWindow?.overrideUserInterfaceStyle = UIUserInterfaceStyle(rawValue: UserDefaults.standard.integer(forKey: "com.weslie.Simple.AppearanceKey")) ?? .unspecified

以上操作完成之后,DarkMode的适配也就基本达成了。但是还有几个点需要注意

[hint]
LaunchScreen的对DarkMode的适配必须要在LaunchScreen.storyboard里面实现,由于它是被打包到Bundle里的,所以好像没有办法通过didFinishLaunchingWithOptions里面再来修改,主要还是加载顺序问题。
[/hint]

其次,如果需要在代码而不是Storyboard中实现动态的颜色设置,需要用到以下的API。这同样也是在session 214中介绍过的:

let dynamicColor = UIColor { (traitCollection: UITraitCollection) -> UIColor in
    if traitCollection.userInterfaceStyle == .dark { 
        return .black
    } else {
        return .white
    }
}
let resolvedColor = dynamicColor.resolvedColor(with: currentView.traitCollection)

语言本地化

关于语言本地化,我感觉还是怎么简单怎么来。后期打算加入对多语言的支持,但是首先最主要的还是把中英文优化做好。
由于需求偏简单,句子的原文和译文是在服务器存储好了的,只有本地的一些菜单或者设置界面需要做适配外,其他不需要特别的适配。
所以整体实现起来还是相对比较简单的,就不封装什么别的函数了,用到的地方也不是很多,于是我们选择最基础的方法:

  1. 选中一个字符串
  2. 右键,选择Refactor
  3. 在重构菜单中选择最后一个包装成NSLocalizedString

所有的Controller里需要进行本地化的字符串都要进行这样一个操作,但是好在其实也不是很多,如果是Storyboard或者Xib的话就更容易了,只需要先选择语言,然后再点击右边的Localization选择需要的语言即可。
在完成对所有需要本地化的字符串包装之后,就可以创建一个叫做InfoPlist.strings,再把刚刚的字符串放进去就行,当然网上关于这方面的教程还是很多的,在此就不在多赘述。

字体的选择*

曾经有用户反馈说中文字体不好看,也有其他的反馈提到英文字体可能比较难辨认。好在WWDC19的session 227更新了关于使用CoreFundaion的框架进行的字体管理,同时也加入了系统级别的自定义管理字体,但是搜了一圈发现好像并没有相关的详细文档说明。简单的来说是通过使用一个Font Provider的App来专门管理下载和卸载字体,代码如下

// FontProvider App — Registering All Fonts
static func registerFamilies(_ names: [FontFamily], _ registrationHandler: (([Error], Bool) -> Bool)?) {
    let fontURLs = names.flatMap({ $0.urls })
    CTFontManagerRegisterFontURLs(fontURLs as CFArray, .persistent, true) {
        (errors: CFArray, done: Bool) -> Bool in
        return processHandler(errors: errors, done: done, registrationHandler) 
    }
}

注册了所有的字体之后,就可以在系统通用设置里面查看到下载好了的字体,然后使用这些多种字体的App就可以通过请求字体来进行使用,代码如下

 // FontConsumer App — Request User Fonts
 ...
let fontsToAsk = Array(theMissingFonts).map { fontName -> UIFontDescriptor in
    return UIFontDescriptor(fontAttributes: 
                            [UIFontDescriptor.AttributeName.name: fontName])
}
CTFontManagerRequestFonts(fontsToAsk as CFArray) { (unresolved: CFArray) in
    DispatchQueue.main.async {
        if checkMissingFontsAndUpdateStringAttributes() || attributesUpdated {
            self.delegate?.textChanged(self.attributedString)
        }
    }
}
 ...

我觉得session 227讲的比较差的一点是,只提供了一些函数的名称,没有给出详细的函数的实现,而且同时也没有任何Demo可以参考,只有大概六七年前的一份古董Demo可以看一下,苹果关于Core系列的API,官方文档从来只有函数的名字,很多的API甚至连它是用来干嘛的都没有讲清,这里手动翻个白眼🙄真的特别气人。

上面的标题加星号*这代表了这一段有太多不确定的因素。至于如何具体使用一种自定义字体,我曾经参考了很多网上的资料,但是并没有找到一篇讲的很完全详尽的,所以这里先做一份保留,等过段时间把坑在踩一下之后,写一篇详细的文章再来说明,如果变勤快了的话,不管你信不信😯反正我是不信。

最近方正字库(就是那个你用它字体的时候不用给钱,后来等你发达了就开始给你发律师函然后告你侵权的方正🤐)好像发布了一款App来可以下载字体给手机使用,不过App Store上面评分好像只有三点几的样子,这种App就是上面所谓的FontProvider App。

关于分享的坑

原来使用的Mob,也就是一个集分享发短信、推送等SDK为一体的一个平台。后来果断舍弃,原因如下:

  • 库非常庞大
  • 界面很low,而且文档更新不及时(到现在还是四五年前的东西)
  • 使用及其不友好,API看着就难受

前段时间在狗乎上面听说,Mob这类的平台短信服务SDK调用完全免费,到底是怎么一回事,后来才知道,他们收入来源一句话总结就是

通过收集手机号私底下有可能在做大数据分析,然后再变相出售到别的平台来赚取收入

至于真实性的话不得而知,做了什么恶也许只有他们自己知道了。但是要知道天下是没有免费的午餐的,自从了解到了这些就彻底奔溃,连夜把Mob所有的服务直接从项目里面彻底铲除,连渣都不剩,有点强迫症甚至把Build Phases都全清了一遍,就差新建项目了。来骗取我们用户的手机号再卖出去的行为真的是忍无可忍了,总之,一句话总结国内的大部分SDK提供商就是

They all sucks!

包括下面服务器中要讲到的曾经我一度崇拜并且喜爱的LeanCloud,同样也列入黑名单,原因的话下面有详细说明

菜单按钮的动画

吐槽了那么多东西,现在继续回到技术层面吧。上面UI部分已经有视频展示了,现在就来看看菜单的动画是怎么实现的。

关于动画,向来都是我的最爱,曾经有写过一个动画呈现代码的playground,这里有演示视频,里面用到了很多CoreAnimation的API。关于动画,看上去好像与技术的关系不是那么大,但它却是一门比较深的学问,关系到了用户的体验。在我学习动画之初,曾经被告诫过一点,那就是“只学习苹果相对封闭和局限的动画框架是没有出路的”,相比之下,更重要的其实是动画本身,而不是怎么用框架。不过基础的API总归还是要会的,我们就来分析一下菜单的动画具体是怎么实现的吧

我觉得动画的精髓还是在于设计,避免不了的是会有大量的计算,比如下面就会提到如何实现一个悬浮卡片的效果。

kite screenshot

首先看到这个菜单按钮,原来是三根横线,在进行动画的时候(点击了评论按钮)会变成一个关闭的X,最初代的Simple也实现了这个动画,但过于复杂并且和其他的动画效果不是很协调,于是在此基础上进行了改进,才有了现在这样的效果。

首先是中间一根线的消失,往左边淡出,然后上下两根线分别旋转和位移之后到最终的X

image

只要计算准确,那么问题应该不大,下面就来看一下代码吧。由于用途比较单一,所以就没有把CoreAnimation的相关调用抽成工厂🏭方法,只是做了简单的封装

import UIKit

class MainMenuItemView: UIView {
  
    // three lines 
    @IBOutlet private weak var menuTopLine: UIView!
    @IBOutlet private weak var menuMiddleLine: UIView!
    @IBOutlet private weak var menuBottomLine: UIView!
    
    // a / 2 = 9
    private let offsetY = 9
    // pi / 4
    private let rotationAngle = Double.pi / 4
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
      
        // anti aliasing
        // This is important!
        menuTopLine.layer.allowsEdgeAntialiasing = true
        menuMiddleLine.layer.allowsEdgeAntialiasing = true
        menuBottomLine.layer.allowsEdgeAntialiasing = true
    }
    
    /// Animate the menu icon
    func animate() {
        // fade out middle line 
        let fadeOutAnim = createAnimation(with: .opacity, startAnimation: true)
        fadeOutAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
        menuMiddleLine.layer.add(fadeOutAnim, forKey: nil)
        let xOffsetAnim = createAnimation(with: .x, startAnimation: true)
        menuMiddleLine.layer.add(xOffsetAnim, forKey: nil)
        
        // move down top line and with rotation
        let moveDownAnim = createAnimation(with: .y, startAnimation: true)
        moveDownAnim.byValue = offsetY
        menuTopLine.layer.add(moveDownAnim, forKey: nil)
        let rotateRightAnim = createAnimation(with: .rotate, startAnimation: true)
        rotateRightAnim.fromValue = 0
        rotateRightAnim.toValue = rotationAngle
        menuTopLine.layer.add(rotateRightAnim, forKey: nil)
        // move up bottom line and with rotation
        let moveUpAnim = createAnimation(with: .y, startAnimation: true)
        moveUpAnim.byValue = -offsetY
        menuBottomLine.layer.add(moveUpAnim, forKey: nil)
        let rotateLeftAnim = createAnimation(with: .rotate, startAnimation: true)
        rotateLeftAnim.fromValue = 0
        rotateLeftAnim.toValue = -rotationAngle
        menuBottomLine.layer.add(rotateLeftAnim, forKey: nil)
        
        // dark mode support
        let interfaceStyle = UIUserInterfaceStyle(rawValue: UserDefaults.standard.integer(forKey: SPAppearance.styleKey)) ?? .unspecified
        overrideUserInterfaceStyle = interfaceStyle
    }
    
    /// Reverse menu icon animation
    func reset() {
        let fadeInAnim = createAnimation(with: .opacity, startAnimation: false)
        menuMiddleLine.layer.add(fadeInAnim, forKey: nil)
        let xOffsetAnim = createAnimation(with: .x, startAnimation: false)
        menuMiddleLine.layer.add(xOffsetAnim, forKey: nil)
        
        let moveUpAnim = createAnimation(with: .y, startAnimation: false)
        moveUpAnim.byValue = -offsetY
        menuTopLine.layer.add(moveUpAnim, forKey: nil)
        let rotateLeftAnim = createAnimation(with: .rotate, startAnimation: false)
        rotateLeftAnim.fromValue = -rotationAngle
        rotateLeftAnim.toValue = 0
        menuTopLine.layer.add(rotateLeftAnim, forKey: nil)
        
        let moveDownAnim = createAnimation(with: .y, startAnimation: false)
        moveDownAnim.byValue = offsetY
        menuBottomLine.layer.add(moveDownAnim, forKey: nil)
        let rotateRightAnim = createAnimation(with: .rotate, startAnimation: false)
        rotateRightAnim.fromValue = rotationAngle
        rotateRightAnim.toValue = 0
        menuBottomLine.layer.add(rotateRightAnim, forKey: nil)
        
        let interfaceStyle = UIUserInterfaceStyle(rawValue: UserDefaults.standard.integer(forKey: SPAppearance.styleKey)) ?? .unspecified
        if !isThemeDisabled && themeURLString != nil {
            overrideUserInterfaceStyle = .light
        } else {
            overrideUserInterfaceStyle = interfaceStyle
        }
    }
    
    private enum AnimationKeyPath: String {
        case opacity = "opacity"
        case x = "position.x"
        case y = "position.y"
        case rotate = "transform.rotation.z"
    }
    
    /// create a CABasicAnimation from keyPath
    private func createAnimation(with keyPath: AnimationKeyPath, startAnimation: Bool) -> CABasicAnimation {
        let anim = CABasicAnimation(keyPath: keyPath.rawValue)
        anim.duration = 0.75
        anim.fillMode = .forwards
        anim.isRemovedOnCompletion = false
        
        // timingFunction
        if keyPath == .x || keyPath == .opacity {
            anim.timingFunction = CAMediaTimingFunction(name: .easeIn)
        } else {
            anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
        }
        
        // from and to value
        switch keyPath {
        case .opacity:
            anim.fromValue = startAnimation ? 1 : 0
            anim.toValue = startAnimation ? 0 : 1
        case .x:
            if startAnimation {
                anim.byValue = -100
            } else {
                let originCenterX = menuMiddleLine.center.x
                anim.fromValue = originCenterX + 30
                anim.toValue = originCenterX
            }
        default: break
        }
        
        // reverse anim set delegate
        if !startAnimation {
            anim.delegate = self
        }
        
        return anim
    }
}

// MARK:- CAAnimationDelegate
extension MainMenuItemView: CAAnimationDelegate {
    func animationDidStart(_ anim: CAAnimation) {
        self.isUserInteractionEnabled = false
    }
    
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        // remove all animations when completed
        menuTopLine.layer.removeAllAnimations()
        menuMiddleLine.layer.removeAllAnimations()
        menuBottomLine.layer.removeAllAnimations()
        self.isUserInteractionEnabled = true
    }
}

悬浮卡片透视动画

除此以外,还有一个是卡片的透视悬浮效果,如下图所示,具体的演示的话可以下载「Simple Reader」,通过按压主页的卡片然后四处滑动,就可以看到卡片跟随手指类似的悬浮透视的效果。

image

进行切变变幻的话,需要用到CATransform3D,当然在进行这些动画之前,想要看到大概的动画效果,可以使用Kite,之前的菜单按钮的动画也使用的Kite,真的是Apple生态中设计动画的得力助手了。通过使用Kite能测试出切边变化的的四阶矩阵\left[\begin{matrix}1 & 0 & 0 & a\\0 & 1 & 0 & b\\0 & 0 & 1 & 0\\0 & 0 & 0 & 1\\\end{matrix}\right],其中 a, b \in [-0.0005, 0.0005],当 a, b 在这个范围内变化的时候,上图中的屏幕中心的卡片进行切变变化能显示出一个比较恰当的效果。那么下面就来计算一下如何从手机在屏幕上的坐标计算得出切变变幻的四阶矩阵,那么这就变成了一个简单的数学问题


已知手指点击的坐标 A(x,y),令屏幕的宽和高分别为 w, h,其中 x \in [0, w]y \in [0, h],求 x \to ay \to b 的映射关系

解:由a \in [-0.0005, 0.0005]x \in [0, w]

那么 x - \frac{w}{2} \in [- \frac{w}{2} , \frac{w}{2} ],两边同时除以一个常数k,则得出
\frac{x - \frac{w}{2}}{k} \in [- \frac{w}{2k} , \frac{w}{2k} ] \iff a \in [-0.0005, 0.0005]
由上式,得 \frac{w}{2k} = 0.0005,那么 k=1000w,同理 k_1=1000h

所以他们的映射关系是
\frac{x - \frac{w}{2}}{1000w} \mapsto a, \frac{y - \frac{h}{2}}{1000h} \mapsto b


致此,求出了k,所以用代码实现起来也就恨方便了,由变幻于需要一个锚点,而我们需要固定在屏幕的中心,所以根据上图的蓝色坐标系,得出锚点 (0.5, 0.5),那么下面的代码实现起来也相对比较简单了,我们首先在卡片View上面加一UILongPressGestureRecognizer,也可以是UIPanPressGestureRecognizer,但是由于它是一个CollectionView会有手势冲突的问题,所以一个讨巧的方法就是给长按手势设置一个较短的时间,比如说0.2秒,那么这个GestureRecognizerselector就如下所示

@objc private func pressCard(_ gesture: UIGestureRecognizer) {
    // get location A(x, y)
    let point = gesture.location(in: self)
    let x = point.x
    let y = point.y
    // screen size w, h
    let width = UIScreen.main.bounds.width
    let height = UIScreen.main.bounds.height
    // x - w / 2
    let numeratorX = x - width / 2.0
    let numeratorY = y - height / 2.0
    // k
    let factorX = 1000 * width * 2.5  // 2.5 和 1.5作为微调的参数
    let factorY = 1000 * height * 1.5
    // a, b
    let m14 = numeratorX / factorX
    let m24 = numeratorY / factorY
    // 前面加负号是给方向取反 
    let transformMatrix = CATransform3D(m11: 1, m12: 0, m13: 0, m14: -m14,
                                        m21: 0, m22: 1, m23: 0, m24: -m24,
                                        m31: 0, m32: 0, m33: 1, m34: 0,
                                        m41: 0, m42: 0, m43: 0, m44: 1)
    
    let anchorPoint = CGPoint(x: 0.5, y: 0.5)
    // animations
    ... ...
}

服务器

LeanCloud

最初的构想是使用LeanCloud作为后端,然后在项目里集成SDK即可,我曾经还因为LeanCloud网页上比较出色的UI,以及对可视化管理表的最初印象较好,所以当初比较崇拜LeanCloud。但是后来事实证明,可能他对于某些特定的场景下会比较友好,比如小型App的开发,后台交互不需要太多,简洁的管理等等,总之还是有点强迫症的原因,后来还是选择了放弃LeanCloud,在此就不做过多的介绍,主要还是讲一下Vapor。

那么在讲Vapor之前,首先还是要来喷一下LeanCloud的

  • API瞎更新,版本号从12.0.0一跳跳到14.0.0
  • 文档更新不及时,有些地方明显的错误
  • API如何调用全靠自己蒙,给你一种你爱用不用的感觉
  • 架构设计对应不同的场景很不合理,比如User表
  • 商业版服务价格偏贵

当然说完了缺点,还是有一些优点的😕

  • 继承好的用户表和绑定手机号,发送验证短信
  • 数据统计
  • 还有一些没有用到的强大功能

Vapor

综上,在最后还是选择了放弃,这就是如何使用纯Swift进行开发。前段时间接触了一下Vapor,看评价都说还可以,于是打算入一下。实践之后发现用Vapor来进行开发,上手还是相对较快的,尤其是对我这种没有接触过后端开发的小白来说还是很友善的。

整个服务器还算比较简单,并没有什么太多复杂的功能,只不过是比较基础的CRUD例如查询句子,评论,添加收藏等等。

首先来看一下用到的几个模型以及其中的关系

image

其中Favorite是中间表,以下是上面几张表对应的Swift代码

  • Quote表
final class Quote: Codable {
    var id: Int?
    var date: String
    var original: String
    var translation: String
    var author: String?
    // ... 
    
    var createdAt: Date?
    static let createdAtKey: TimestampKey? = \.createdAt
    
    struct FavoriteSummary: Content {
        var date: String
        var count: Int
        var qid: Int?
    }
}

extension Quote: PostgreSQLModel {}
extension Quote: Content {}
extension Quote: Parameter {}
extension Quote: Migration {}

extension Quote {
    // Quote 与 Comment 一对多
    var comments: Children<Quote, Comment> {
        return children(\.qid)
    }
    // Quote -> User 的 Favorite 表,多对多
    var favUsers: Siblings<Quote, User, Favorite> {
        return siblings()
    }
}
  • User表
final class User: Codable {
    // 用户实体唯一标示,创建用户后生成,返回给客户端再存储到iCloud上
    var id: UUID?
    var username: String
    var avatarId: Int
    // ...
}

extension User: PostgreSQLUUIDModel {}
extension User: Content {}
extension User: Parameter {}
extension User: Migration {}

extension User {
    // User 与 Comment 一对多
    var comments: Children<User, Comment> {
        return children(\.uid)
    }
    // User -> Quote 的 Favorite 表,多对多
    var favedQuotes: Siblings<User, Quote, Favorite> {
        return siblings()
    }
}
  • Favorite表
final class Favorite: PostgreSQLPivot {
    var id: Int?
    var uid: User.ID
    var qid: Quote.ID

    // 定义中间表的左右键
    typealias Left = Quote
    typealias Right = User
    static let leftIDKey: LeftIDKey = \.qid
    static let rightIDKey: RightIDKey = \.uid
  
    // ...
    
    init(_ quote: Quote, _ user: User) throws {
        self.qid = try quote.requireID()
        self.uid = try user.requireID()
    }
}

extension Favorite: ModifiablePivot {}

extension Favorite: Migration {
    // 初始化外键,建立多对多关系
    static func prepare(on connection: PostgreSQLConnection) -> Future<Void> {
        return Database.create(self, on: connection) { builder in
            try addProperties(to: builder)
            builder.reference(from: \.qid, to: \Quote.id)
            builder.reference(from: \.uid, to: \User.id)
        }
    }
}
  • Comment表
final class Comment: Codable {
    var id: Int?
    var content: String
    
    var qid: Quote.ID
    var uid: User.ID
  
    // ...
}

extension Comment: PostgreSQLModel {}
extension Comment: Content {}
extension Comment: Parameter {}

extension Comment {
    // 一条 Comment 一个 Quote 对应
    var quote: Parent<Comment, Quote> {
        return parent(\.qid)
    }
    // 一条 Comment 一个 User 对应
    var user: Parent<Comment, User> {
        return parent(\.uid)
    }
}

extension Comment: Migration {
    // 初始化外键,建立多对多关系
    static func prepare(on connection: PostgreSQLConnection) -> Future<Void> {
        return Database.create(self, on: connection) { builder in
            try addProperties(to: builder)
            builder.reference(from: \.qid, to: \Quote.id)
            builder.reference(from: \.uid, to: \User.id)
        }
    }
}

最基本的API即为获取所有的句子,通常一张表会对应很多的API,因此新建一个Controller,再后端开发中Controller更像是管理一系列API的集合,和ViewController有些类似。

import Vapor
import Fluent

struct QuotesController: RouteCollection {
    func boot(router: Router) throws {
        // 路由,当Vapor部署后即可通过 <domain-name>/api/quotes 域名进行以下的请求
        let quoteRoutes = router.grouped("api", "quotes")
        quoteRoutes.post(Quote.self, use: createQuoteHandler)
        quoteRoutes.get(use: getAllHandler)
    }
    
    /// create a quote
    func createQuoteHandler(_ req: Request, quote: Quote) throws -> Future<Quote> {
        return quote.save(on: req)
    }
    
    /// query all quotes
    func getAllHandler(_ req: Request) throws -> Future<[Quote]> {
        return Quote.query(on: req).sort(\.date).all()
    }   
}

新建的Controller需要在router.swift中注册路由

public func routes(_ router: Router) throws {
    // MARK:- Quotes
    let quotesController = QuotesController()
    try router.register(collection: quotesController)
}

注册后需要在configure.swift中进行合并

var migrations = MigrationConfig()
migrations.add(model: Quote.self, database: .psql)
services.register(migrations)

以上我们完成了QuotesController的搭建,那么下面来了一个新的需求,同样在客户端里面,我需要根据当天句子的id,查找出这条句子的所有评论,看上去很简单,只是一个连接表的操作,把Quote,Comment,User三张表join一下即可,然后查出所有的Comment在返回,但是事实上并不是。由于Comment在设计的时候只相当于一张中间表,通过uid的外键对应User,而且目前的状况是,每条评论需要有用户的头像信息,用户名信息,评论的具体内容,时间等等。所以我们在Comment这个模型里面新定义一个数据结构,用来作为此次查询的返回类型

struct Info: Content {
    var cid: Int?
    var content: String
    var user: User
}

这样返回类型就写作Future<[Comment.Info]>,然后进行查表操作

  • URL:<domain-name>/api/comments/quote?qid=123

  • 请求参数:句子id,Int类型

  • 返回类型:Comment.Info数组

字段名 类型
cid Int
content String
user User

不直接使用join,查询逻辑就成了

  • 根据qid查询Quote表,访问quote.comment(一对多关系)得到所有的Comment
  • Comment数组变幻成Comment.Info数组,然后返回Future<[Comment.Info]>

具体的代码如下

/// query all comments with qid
func getQuoteCommentsHandler(_ req: Request) throws -> Future<[Comment.Info]> {
    // 首先需要根据URL参数来查询对应的那一条Quote
    guard let qid = req.query[Quote.ID.self, at: "qid"] else {
        throw Abort(.badRequest, reason: "Quote not found")
    }
    // 查询 Quote 表,找到对应的 Quote
    return Quote.query(on: req).filter(\.id == qid).first().flatMap { (quote: Quote?) -> Future<[Comment.Info]> in
        // Quote 与 Comment 为一对多关系,访问其 Children 即 comments 属性,查出所有的 Comment
        try quote!.comments.query(on: req).all().flatMap { (comments: [Comment]) -> Future<[Comment.Info]> in
            // 把查询到的 [Comment] 进行 map 得到 [Comment.Info]
            return comments.map { (comment: Comment) -> Future<Comment.Info> in
                // Comment -> User 为一对一关系(反之一对多),访问其 Parent 即 user 属性,查出对应的那一个 User,在进行 map 变幻
                return comment.user.get(on: req).map { (user: User) -> Comment.Info in
                    // 得到所有需要的信息后,使用 Comment.Info 来创建一个实例,进行返回
                    return Comment.Info(content: comment.content, user: user, cid: comment.id)
                }
            // map 是把确定类型 [Comment] 映射成确定类型 [Comment.Info],使用 flatten 包装成 Future<[Comment.Info]> 类型
            }.flatten(on: req)
        }
    }
}    

为了代码的简洁性,可以直接省略mapflatMap尾随闭包里的给参数和返回值显示地标记类型,当然这里为了方便理解所以加上了,当然在写代码的过程中处理好这些高阶函数尤为重要。

API开发完了之后就可以开始本地测试了,测试通了之后就可以部署到服务器上了。

simple-server-api-test-paw-demo

以上就是服务端的解析,当然只是很浅显的一部分,完整的一套API还需要很多。当这些工作都完成了之后,就可以抛弃localhost并部署了,关于如何手动部署到服务器,请参考在 Ubuntu18.04 上部署 Vapor💧和 PostgreSQL🐘

总结

从当初对后端的一无所知到现在能写出一些简单的请求,还算是一个大进步了,同时也感谢之前@Jacky大佬的帮忙,以后的作品不出意外的话大部分还会采用Vapor进行主力开发,毕竟感觉还不错。


讲到这边,技术方面的东西就差不多结束了,但是现实中却还远远没有结束,因为有太多的地方需要你去考虑,去琢磨。作为独立开发出一款App,很多地方也是没有办法去考虑到的,首先没有一个完整的测试体系,QA也是不存在的,这辈子都不会有了。光靠着我自己一个人开发,能收到的bug反馈也只能从后来真正下载了去用了的用户那边得到,所以测试方面,我认为是一个问题。同时,很多的细节上,尤其是那些不起眼的地方,比如说一个小小的动画,可能看上去没什么,但是背后却需要花费很多时间。这才体会到什么叫做Code with Love❤️一切的一切都完全出自于兴趣,出于爱,仅此而已。

运营

做这个App到现在也经历了很久了,虽然具体算在开发上面的时间也不是很多,但是投入真的很多,还有我的好朋友@小党,感谢他一直以来在背后默默的奉献,找了那么多好的句子,同时还运营了一个微博账号@SimpleReader

Simple的初衷,还是我个人的爱好,把它付诸实践,真的比想象中的要难很多。一开始下载的用户量真的太少,关于推广的话,没有做过任何的广告,只有在身边的朋友推广,当时的用户量大概在几十个,上百个。后来去了WWDC,苹果在App Store帮推了两次

少年开发者: 从零踏上 App 开发之旅

WWDC19 Scholarships

就在那一个星期,下载量就涨了大几千,用户也直接上千了。后来风头过了之后也就变回来日常的下载量了,后来我想了想,也没有必要做太多的什么宣传了,一切都看缘分吧~

后记

没有后记,都在上面了🤪

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342