开源项目源码分析(Kickstarter-iOS )(一)
1.Kickstarter开源项目简介
- 2016年12月15日,知名众筹平台Kickstarter在工程博客中宣布,将开源Android和iOS端的源代码,从而为初创企业提供更多的便利和帮助。由于这个伟大的决定我们这些小白才能有机会学习大神之作,Kickstarter是一个非常NB的项目,值得我们去研究它的源码:点击这里下载Kickstarter项目源码
- Kickstarter IOS app源码下载
- Kickstarter Android app源码下载
我们自己去啃这些开源源码是有一点难度的,这里介绍一本很好的书籍: Raywenderlich 的一本书 《Advanced iOS App Architecture》在介绍 MVVM 架构的时候,说到 Kickstarter 很彻底地遵循了 MVVM 架构。
-
首先第一感觉是代码非常令人爽心悦目,因为代码非常整洁。再仔细一看,发现里面有很多值得学习的地方,项目使用swift5.0编写,真正的使用MVVM架构模式,架构清晰,非常值得学习。
-
下面看几张项目架构图片:
-
用到的框架:
-
用到的第三方工具:
-
MVVM模式
2. Kickstarter项目结构
2.1 Makefile 文件
- 在把项目 clone 下来之后,我们一般首先会想着怎么把它运行起来。在项目的 readme 中的 Getting Started 我们可以看到,运行 make bootstrap安装工具和依赖,运行 make test-all 构建项目并进行测试。而这两个命令就是在 Makefile 中定义的。
ios git clone地址:https://github.com/kickstarter/ios-oss
android git clone地址:https://github.com/kickstarter/android-oss
- 打开 Makefile 文件,我们可以从中看到:1)文件的开头定义了各种变量;2)剩下的是项目中用到的命令。我们以 make bootstrap 为例:
bootstrap: hooks dependencies
brew update || brew update
brew unlink swiftlint || true
brew install https://raw.githubusercontent.com/Homebrew/homebrew-core/686375d8bc672a439ca9fcf27794a394239b3ee6/Formula/swiftlint.rb
brew switch swiftlint 0.29.2
brew link --overwrite swiftlint
- 执行 make bootstrap ,就会依次执行 bootstrap 下面包含的所有命令。
- 使用 Makefile 的好处是,我们可以把项目相关的一些命令操作都放到这个文件,即便是刚刚接手项目的同事也一目了然。
2.2 Git submodule
- 下载源码用xcode打开后你肯定会好奇,这么NB的工程居然没有用cocoapod,那么项目没有使用第三方框架么?
- 答案是否定的。把项目 clone 下来之后,我们确实会发现文件夹里面没有我们常用的
Podfile
和xcworkspace
文件。然而,Kickstarter 不是用Cocoapods
来管理第三方库的,而是使用git submodule
。 - 其实除了上面提到的两个管理第三方框架的工具之后,还可以用
Carthage
来管理第三方库。找到一篇文章:对比Carthage和Cocoapods和Git submodule,描述了这三种工具的优缺点。 - 至于选择哪一种,就看我们更看重的是什么了。我一般都是使用的cocoapods.
2.3 脚本工具
- 在根目录下的 bin 目录,我们可以看到两个用 Swift 编写的脚本:
ColorScript
和StringsScript
。
2.3.1 ColorScript脚本
- 开发者把项目中用到的颜色,保存在Colors.json文件,然后通过 ColorScript 转换成 Colors.swift文件。开发者在使用的时候只需要通过 UIColor.ksr_dark_grey_400就能得到相应的颜色了。后续如果 UI 设计师想要微调颜色,直接修改颜色, json 中的 key 的值不变,我们只需要重新生成 Colors.swift就都搞定了,而不需要更改代码。
{
"apricot_600": "FFCBA9",
"cobalt_500": "4C6CF8",
"dark_grey_400": "9B9E9E",
"dark_grey_500": "656868",
"facebookBlue": "3B5998",
...
}
- 这种统一管理颜色的方法,我觉得其实就是把颜色管理的工作交给 UI 设计师了。设计师写好
json
文件,交给开发者,开发者用脚本生成Colors.swift
,就一切都搞定了(如果颜色名字有变动或有新添加的颜色,还是需要开发者手动更改和添加)。如果不通过这种方法去做,而是开发者自己手动去写,那么可能会经常去手动修改Colors.swift
,这样就麻烦一些。
2.3.2 StringsScript脚本
- 做过国际化的开发者应该知道,如果不通过其他处理的话,我们需要通过
NSLocalizedString("Hello_World", comment: "")
去获取对应的本地化字符串,这种写法非常麻烦,而且很容易出错。 - 在 Kickstarter-iOS 中,开发者用
StringsScript
把Localizable.strings
转换生成Strings.swift
文件,然后我们在使用的时候,就可以像这样去获取想要的字符串Strings. Hello_World()
。这个脚本把 key 变成了方法名,让我们避免了在使用的时候出现错误,而且使用起来非常方便。 - 如果有做本地化的项目,采用这种方法可以给开发者带来很大的便利。
2.4 测试工具
- 测试,是软件开发中非常重要的一个环节。甚至有些公司执行 TDD (测试驱动开发(Test-Driven Development)),可以见测试的重要性。
- 在 Kickstarter-iOS 中,我们可以看到大量的 xxxTests.swift文件,包括了
Unit Test
和UI Test
。
2.5 独立的代码库
- 用 Xcode 打开 Kickstarter-iOS 的项目,你会发现
KsApi
、Library
和LiveStream
这三个文件夹不是存放在 Kickstarter-iOS文件夹里面的,而是跟它处于同一个目录。因为这三个文件夹存放的是独立于 Kickstarter-iOS 之外的 framework
- 这么做的好处当然是代码可以复用。目前我看 iPad 上的 Kickstarter 应用是跟 iPhone 共用一个的,如果以后要为 iPad 单独做一个 app,这三个 frameworks 就可以直接拿过去用。
3. Kickstarter项目MVVM架构
3.1 MVVM架构思想简介
-
MVVM架构思想
这里有一个讲解 MVVM & TDD 的视频。感兴趣的可以看一下。 -
函数响应式编程思想
代表主要有Rxswift, RAC, ReactiveSwift -
ReactiveSwift 是一个响应式编程的库,与 RxSwift 类似,这两个库非常适用于 MVVM 架构。至于要选择哪一种,可以先去了解下他们的差别,然后再决定.这里有篇很好的文章讲解了他们的区别:How does ReactiveSwift relate to RxSwift?
-
Kickstarter-iOS 把
MVVM
模式贯彻地非常彻底。MVVM 的全称是 Model-View-ViewModel,所以我们可能会觉得要有View
存在的地方,才可以用ViewModel
。但是 Kickstarter-iOS 在AppDelegate
中也使用了ViewModel
,把很多在AppDelegate
处理的逻辑剥离到AppDelegateViewModelType
中。
3.2 MVVM架构实际运用
3.2.1 使用 ReactiveSwift
- 我们平常很多项目一般都是使用Rxswift + MVVM架构模式这样搭配,然后会用到网络库Alamofire + Moya + Rxswift .数据库一般用FMDB.
- 响应式编程非常适合 MVVM 架构。在 ViewModel 中,我们通常会使用 ReactiveSwift 或者 RxSwift 去定义一些属性,然后在 UIView 和 UIViewController中的 bindViewModel() 方法里面订阅那些属性的变化,然后更新 UI。
- Kickstarter-iOS项目基本就是用 ReactiveSwift + MVVM这种。
3.2.2 UIView
- 对于
UIView
,Kickstarter 通过扩展重写awakeFromNib()
,在内部调用bindViewModel()
。代码如下:
extension UIView {
open override func awakeFromNib() {
super.awakeFromNib()
self.bindViewModel()
}
@objc open func bindViewModel() {
}
}
- 因为 Kickstarter 在整个项目中都是通过 xib 来构建 UI 的,所以 UI 在初始化时,
awakeFromNib()
会被调用,从而bindViewModel()
也被调用。那么在其他继承自UIView
的view
中,只需要重写bindViewModel()
,就能达到绑定ViewModel
的目的。
3.2.3 UIViewController
-
在
UIViewController
中就会稍微复杂一点。Kickstarter 通过runtime
,默认在viewDidLoad()
中调用bindViewModel()
。那么在其他继承自UIViewController
的ViewController
中,只需要重写bindViewModel()
,就能达到绑定ViewModel
的目的。 -
UIViewController-Preparation.swift
相关代码如下:
private func swizzle(_ vc: UIViewController.Type) {
[
(#selector(vc.viewDidLoad), #selector(vc.ksr_viewDidLoad)),
(#selector(vc.viewWillAppear(_:)), #selector(vc.ksr_viewWillAppear(_:))),
(#selector(vc.traitCollectionDidChange(_:)), #selector(vc.ksr_traitCollectionDidChange(_:))),
].forEach { original, swizzled in
guard let originalMethod = class_getInstanceMethod(vc, original),
let swizzledMethod = class_getInstanceMethod(vc, swizzled) else { return }
let didAddViewDidLoadMethod = class_addMethod(vc,
original,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod))
if didAddViewDidLoadMethod {
class_replaceMethod(vc,
swizzled,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod))
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
}
private var hasSwizzled = false
extension UIViewController {
final public class func doBadSwizzleStuff() {
guard !hasSwizzled else { return }
hasSwizzled = true
swizzle(self)
}
@objc internal func ksr_viewDidLoad() {
self.ksr_viewDidLoad()
self.bindViewModel()
}
/**
The entry point to bind all view model outputs. Called just before `viewDidLoad`.
*/
@objc open func bindViewModel() {
}
}
- 然后在 AppDelegate.swift中的 didFinishLaunchingWithOptions调用 doBadSwizzleStuff(),代码如下:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UIViewController.doBadSwizzleStuff()
}
- 通过这两个处理,就能避免编写大量的重复代码
3.2.4 ViewModel
- 我从项目中找了一个代码量比较少的 ViewModel 文件
HelpWebViewModel.swift
,以这个文件为例。具体代码如下:
import Library
import Prelude
import ReactiveSwift
import Result
internal protocol HelpWebViewModelInputs {
/// Call to configure with HelpType.
func configureWith(helpType: HelpType)
/// Call when the view loads.
func viewDidLoad()
}
internal protocol HelpWebViewModelOutputs {
/// Emits a request that should be loaded into the webview.
var webViewLoadRequest: Signal<URLRequest, NoError> { get }
}
internal protocol HelpWebViewModelType {
var inputs: HelpWebViewModelInputs { get }
var outputs: HelpWebViewModelOutputs { get }
}
internal final class HelpWebViewModel: HelpWebViewModelType, HelpWebViewModelInputs, HelpWebViewModelOutputs {
internal init() {
self.webViewLoadRequest = self.helpTypeProperty.signal.skipNil()
.takeWhen(self.viewDidLoadProperty.signal)
.map { urlForHelpType($0, baseUrl: AppEnvironment.current.apiService.serverConfig.webBaseUrl) }
.skipNil()
.map { AppEnvironment.current.apiService.preparedRequest(forURL: $0) }
}
internal var inputs: HelpWebViewModelInputs { return self }
internal var outputs: HelpWebViewModelOutputs { return self }
internal let webViewLoadRequest: Signal<URLRequest, NoError>
fileprivate let helpTypeProperty = MutableProperty<HelpType?>(nil)
func configureWith(helpType: HelpType) {
self.helpTypeProperty.value = helpType
}
fileprivate let viewDidLoadProperty = MutableProperty(())
func viewDidLoad() {
self.viewDidLoadProperty.value = ()
}
}
private func urlForHelpType(_ helpType: HelpType, baseUrl: URL) -> URL? {
switch helpType {
case .cookie:
return baseUrl.appendingPathComponent("cookies")
case .contact:
return nil
case .helpCenter:
return baseUrl.appendingPathComponent("help")
case .howItWorks:
return baseUrl.appendingPathComponent("about")
case .privacy:
return baseUrl.appendingPathComponent("privacy")
case .terms:
return baseUrl.appendingPathComponent("terms-of-use")
case .trust:
return baseUrl.appendingPathComponent("trust")
}
}
- 对于 ViewModel,我想说两点:1)使用 ReactiveSwift;2)使用 inputs 和 outputs 区分数据的输入和输出。
3.2.5 Model
3.2.6 bindViewModel()
- 在 MVVM 架构中,一般来说
ViewModel
是被UIView
和UIViewController
持有,而持有 ViewModel 的对象就需要绑定到ViewModel
,这样就能响应ViewModel
中数据的变化,从而更新 UI。一般我们都会在持有ViewModel
的对象中定义一个方法bindViewModel()
,并且在这个方法里面做绑定。 - Kickstarter 分别在
UIView
和UIViewController
做了一些处理,让程序在启动的时候就默认在各自内部的方法调用了bindViewModel()
,这样可以避免在很多的View
和ViewController
中写重复的代码。
3.2.7 使用 inputs 和 outputs 区分数据的输入和输出
- 在
ViewModel
中,我们需要接受外部的信息输入,并且告诉外部有哪些信息发生了变化。 - Kickstarter-iOS 把信息的输入和输出分别用
HelpWebViewModelInputs
和HelpWebViewModelOutputs
分开,这样在使用ViewModel
的时候就会非常清晰,不会把inputs
和outputs
混在一起。例如,我们在Xcode 中编写viewModel.outputs
. 时,Xcode 只会提示webViewLoadRequest
,而不会把属于inputs
的viewDidLoad()
也显示给我们。 - 这在我们使用
ViewModel
的时候带来了极大的便利,并且让看代码的人一目了然,哪些代码处理输入,哪些代码处理输出,非常清晰。
3.2.8
3.3 Environment
- 有经验的 iOS 开发者应该都知道,在开发过程中我们需要设计一些对象来存储应用的全局状态,例如当前的登录用户等等。而在 Kickstarter-iOS 中,
Environment
和AppEnvironment
就是干这事的。
3.3.1 Environment
- 打开这个文件,从注释可以看到,Environment 是应用所需要的全局变量和单例的集合。仔细分析里面属性的定义,我们可以发现很多都是属于 protocol 类型的,例如:
public let apiService: ServiceType
public let cookieStorage: HTTPCookieStorageProtocol
public let device: UIDeviceType
public let ubiquitousStore: KeyValueStoreType
public let userDefaults: KeyValueStoreType
- 这么做的好处是当有需要的时候,可以随时替换另外一个遵循对应 protocol的对象。这也就是我们所说的面向协议编程。
3.3.2 AppEnvironment
- 刚开始看这个项目,看到有 Environment 和 AppEnvironment,可能会觉得有点困惑,为什么有了 Environment,还要搞一个AppEnvironment?下面我们来仔细看看。
- 先看一下 AppEnvironment 里面的方法:
public struct AppEnvironment : AppEnvironmentType {
internal static let environmentStorageKey: String
internal static let oauthTokenStorageKey: String
public static func login(_ envelope: AccessTokenEnvelope)
public static func updateCurrentUser(_ user: User)
public static func updateServerConfig(_ config: ServerConfigType)
public static func updateConfig(_ config: Config)
public static func updateLanguage(_ language: Language)
public static func logout()
public static var current: Environment! { get }
public static func pushEnvironment(_ env: Environment)
public static func popEnvironment() -> Environment?
public static func replaceCurrentEnvironment(_ env: Environment)
// 参数太长,省略了
public static func pushEnvironment(...)
// 参数太长,省略了
public static func replaceCurrentEnvironment(...)
public static func fromStorage(ubiquitousStore: KeyValueStoreType, userDefaults: KeyValueStoreType) -> Environment
internal static func saveEnvironment(environment env: Environment = AppEnvironment.current, ubiquitousStore: KeyValueStoreType, userDefaults: KeyValueStoreType)
}
- 从上面的方法我们可以总结出,
AppEnvironment
是用来管理Environment
。如果我们不新建一个 AppEnvironment,那么这些管理代码就会放到 Environment,这会造成在一个Model
上进行业务逻辑的处理,而这明显是不合理的。 - 如果你在项目中全局搜索
pushEnvironment
和popEnvironment
,你会发现,这两个方法都是在测试文件中被调用,说明这两个方法是为测试而生的。 - 另外
AppEnvironment
还提供了replaceCurrentEnvironment()
方法,携带了所有对应Environment
的参数,这可以让我们很容易替换当前Environment
的某个全局变量。例如在AppDelegate.swift
我们可以看到:
#if DEBUG
if KsApi.Secrets.isOSS {
AppEnvironment.replaceCurrentEnvironment(apiService: MockService())
}
#endif
- 把
KsApi.Secrets.isOSS
设置为true
之后,我们就可以使用MockService()
,实在是非常方便。
3.4 网络请求的处理
- 从
Environment
中,可以了解到Service
是处理应用中所有网络请求的。进入到 Service, 这里编写了所有的网络请求方法。再仔细看,你会发现很多请求是通过类似request(.facebookConnect(facebookAccessToken: token))
去调用的。我们就先来看看这个request()
方法的参数Route
。 - Route 的部分代码如下:
internal enum Route {
case activities(categories: [Activity.Category], count: Int?)
case addImage(fileUrl: URL, toDraft: UpdateDraft)
case addVideo(fileUrl: URL, toDraft: UpdateDraft)
case backing(projectId: Int, backerId: Int)
// ...
internal var requestProperties:
(method: Method, path: String, query: [String: Any], file: (name: UploadParam, url: URL)?) {
switch self {
case let .activities(categories, count):
var params: [String: Any] = ["categories": categories.map { $0.rawValue }]
params["count"] = count
return (.GET, "/v1/activities", params, nil)
case let .addImage(file, draft):
return (.POST, "/v1/projects/\(draft.update.projectId)/updates/draft/images", [:], (.image, file))
case let .addVideo(file, draft):
return (.POST, "/v1/projects/\(draft.update.projectId)/updates/draft/video", [:], (.video, file))
case let .backing(projectId, backerId):
return (.GET, "/v1/projects/\(projectId)/backers/\(backerId)", [:], nil)
// ...
}
}
}
- 如果你打开源文件,你会发现,
Route
枚举编写了所有用到的请求,并且定义了requestProperties
属性,这样我们就可以通过类似.facebookConnect(facebookAccessToken: token)
去获取到想要的请求,然后通过requestProperties
属性,获取到请求参数,接着做进一步的网络请求。 - 对于类似这种有多种可能情况的处理,用 enum 非常合适,而这也是开发过程中经常会遇到的。
- 既然各种请求都准备好了,下一步就要进行真正的网络请求了,这些代码就藏在
Service+RequestHelpers.swift
。
3.4.1 Service+RequestHelpers
- 这个文件暴露给外面的接口非常简单,如下:
extension Service {
func fetch<A: Swift.Decodable>(query: NonEmptySet<Query>) -> SignalProducer<A, GraphError>
func applyMutation<A: Swift.Decodable, B: GraphMutation>(mutation: B) -> SignalProducer<A, GraphError>
func requestPagination<M: Argo.Decodable>(_ paginationUrl: String)
-> SignalProducer<M, ErrorEnvelope> where M == M.DecodedType
func request<M: Argo.Decodable>(_ route: Route)
-> SignalProducer<M, ErrorEnvelope> where M == M.DecodedType
func request<M: Argo.Decodable>(_ route: Route)
-> SignalProducer<[M], ErrorEnvelope> where M == M.DecodedType
func request<M: Argo.Decodable>(_ route: Route)
-> SignalProducer<M?, ErrorEnvelope> where M == M.DecodedType
}
- 从这些方法的定义我们可以看到,全部使用了泛型,这就意味着一个方法就可以处理某一类型的请求。这六个方法就可以处理整个应用的请求,是不是觉得非常强大??
- 这也是值得我们学习的地方。所以在开发过程中,如果发现自己在重复写类似的代码,那么可以考虑使用泛型能不能解决问题。
3.4.2 Deep Linking
- 在开发中,我们通常需要通过
Universal Link
、URL Scheme
和Push Notification
等方式跳转到应用的某一个页面。我们来看一下 Kickstarter-iOS 是怎么处理的。 - 打开
Navigation.swift
,跟网络请求一样,也是用enum
定义了所有用户去往的目标页面。 - 那在 Kickstarter-iOS 中,它是怎样通过
deep linking
传入的url
来最终得到Navigation
其中的一个case
,然后跳转到目标页面呢? - 首先,它用一个字典
allRoutes: [String: (RouteParams) -> Decoded<Navigation>]
保存了所有的routes
:其中key
是url
的模板;value
是一个闭包,这个闭包是根据url
携带的参数解析成Navigation
。 - 然后用一个
match()
方法,把传入的url
,最终解析成Navigation
这里面最关键的一个方法是parsedParams()
,大家可以去仔细看一下怎么实现的。
extension Navigation {
public static func match(_ url: URL) -> Navigation? {
return allRoutes.reduce(nil) { accum, templateAndRoute in
let (template, route) = templateAndRoute
return accum ?? parsedParams(url: url, fromTemplate: template).flatMap(route)?.value
}
}
}
3.5 用 Storyboard / Xib 创建 UI
-
以前,我们经常看到开发者们在争论:对于 UI 的创建,纯代码手写好还是用 Storyboard / Xib 好?这里就不对这个话题展开了,这么久过去了,相信各位开发者在自己的心里已经有了答案。下面我们看看 Kickstarter 是如何使用
Storyboard / Xib
来创建UI
的。 -
首先告诉大家,Kickstarter的 UI 几乎都是用
Storyboard / Xib
来完成的。打开Kickstarter-iOS/Views/Storyboards
文件夹,这里存储了应用的全部.storyboard
和.xib
文件。 -
使用 Storyboard 创建 UI,最怕的就是一个
.storyboard
文件包含了太多的ViewController
。所以 Kickstarter 为每一个小模块的功能单独创建了一个Storyboard
,并且当你点开每一个Storyboard
,你会发现大部分Storyboard
只有一个ViewController
。这也很好解决了多人同时编辑一个Storyboard
时导致的代码冲突问题,因为我们一般不会多人同时去开发一个小模块,把Storyboard
分得很细之后,就不会出现多人同时编辑一个Storyboard
的情况。 -
另外,Kickstarter 还定义了
Storyboard
和Nib
枚举,列举了所有的Storyboard
和xib
文件,方便ViewController
和View
的初始化,这是一个非常漂亮的处理(以下代码省略了方法的具体实现)
import UIKit
public enum Storyboard: String {
case Activity
case Backing
case BackerDashboard
// ...
public func instantiate<VC: UIViewController>(_ viewController: VC.Type,
inBundle bundle: Bundle = .framework) -> VC
}
import UIKit
public enum Nib: String {
case BackerDashboardEmptyStateCell
case BackerDashboardProjectCell
case CreditCardCell
// ...
}
extension UITableView {
public func register(nib: Nib, inBundle bundle: Bundle = .framework)
public func registerHeaderFooter(nib: Nib, inBundle bundle: Bundle = .framework)
}
protocol NibLoading {
associatedtype CustomNibType
static func fromNib(nib: Nib) -> CustomNibType?
}
extension NibLoading {
static func fromNib(nib: Nib) -> Self?
func view(fromNib nib: Nib) -> UIView?
}
3.6 PDF 格式的图标
- 在过去的 iOS 项目中,一般都使用
png
格式的图标。而在 Kickstarter 中,使用的是pdf
格式的图标。我们先来看下pdf
格式的图标有什么优点? - PDF 的全称是 Portable Document Format,是用于正确显示文档和图形的图像格式。PDF文件具有强大的矢量图形基础,可以用来保矢量图像。矢量图像本质上是巨大的数学方程,每个点、线和形状都由自己的方程表示。每一个“方程式”都可以被指定一种颜色、笔画或厚度来将形状变成艺术。与光栅图像不同,矢量图像与分辨率无关。当你缩小或放大一个矢量图像时,你的形状会变大,但你不会丢失任何细节或得到任何像素。因为您的图像将始终以相同的方式呈现,无论大小如何,都不存在有损或无损矢量图像类型。矢量图像通常用于logo、图标、排版和数字插图。
- 从上面我们可以了解到
pdf
格式的图标最大的优点是可以无损放大。还有,只需要一个 pdf 文件就可以代表一个图标,而png 图片一般至少需要两个(2x和 3x, 1x 一般不需要了)。除了这两个优点之外,我还发现 Kickstarter 中的 pdf 文件的大小只有 5k左右;而我们现有的项目中一个 png 图片就有 15k左右,两个 png 就 30k了,所以,使用 pdf 图片还可以一定程度上减少应用的大小。
3.7 单元测试
- 在 Kickstarter-iOS 中,单元测试的对象主要分两类:Model 和 ViewModel。
3.7.1 Model
- 在定义一个 Model 时,一般都会实现 Codable,并且要测试一下对于给定的 json 数据,是否可以解析成功。Kickstarter-iOS 也是这么做的:在每一个 Model 对应的测试文件里,利用假的 json 数据,测试是否可以解析成功。
- 例如 AuthorTests.swift里:
func testJSONParsing_WithCompleteData() {
let author = Author.decodeJSONDictionary([
"id": 382491714,
"name": "Nino Teixeira",
"avatar": [
"thumb": "https://ksr-qa-ugc.imgix.net/thumb.jpg",
"small": "https://ksr-qa-ugc.imgix.net/small.jpg",
"medium": "https://ksr-qa-ugc.imgix.net/medium.jpg"
],
"urls": [
"web": [
"user": "https://staging.kickstarter.com/profile/382491714"
],
"api": [
"user": "https://api-staging.kickstarter.com/v1/users/382491714"
]
]
])
XCTAssertNil(author.error)
XCTAssertEqual(382491714, author.value?.id)
}
3.7.2 ViewModel
- 在 Kickstarter-iOS 中,每个 ViewModel 都会有对应的测试。这里主要讲一下有哪些小技巧值得学习的。
- 在
XCTestCase+AppEnvironment.swift
中, 通过扩展XCTestCase
定义了withEnvironment()
方法,用于替换某些全局变量,把替换后的Environment push
到stack
中作为当前的Environment
,执行完body()
后,再把刚刚push
的Environment
移除,这样可以保证不改变测试前后的Environment
。
func withEnvironment(_ env: Environment, body: () -> Void) {
AppEnvironment.pushEnvironment(env)
body()
AppEnvironment.popEnvironment()
}
func withEnvironment(...) # 具体看文件
- 基本上每一个 Model 都会定义一个 template 实例,用于在 ViewModel 中测试。
3.7.3 UI 测试
- 在 Kickstarter-iOS 中,UI 测试主要是对 ViewController 的测试,看看 UI 的显示是否有问题。
- 因为 Kickstarter 支持多语言,并且 iOS 设备有多种尺寸,所以定义了一个 combos 方法,用于组合各种语言和尺寸:
internal func combos<A, B>(_ xs: [A], _ ys: [B]) -> [(A, B)] {
return xs.flatMap { x in
return ys.map { y in
return (x, y)
}
}
}
- 另外还定义了一个方法,根据设备的大小和朝向最终把传入的 controller 转变成对应设备大小的 controller。
internal func traitControllers(device: Device = .phone4_7inch,
orientation: Orientation = .portrait,
child: UIViewController = UIViewController(),
additionalTraits: UITraitCollection = .init(),
handleAppearanceTransition: Bool = true)
-> (parent: UIViewController, child: UIViewController)
- 最后再用 FBSnapshotTestCase 生成各种尺寸语言组合的截图,具体代码如下:
func testAddNewCard() {
combos(Language.allLanguages, Device.allCases).forEach { language, device in
withEnvironment(language: language) {
let controller = AddNewCardViewController.instantiate()
let (parent, _) = traitControllers(device: device, orientation: .portrait, child: controller)
FBSnapshotVerifyView(parent.view, identifier: "lang_\(language)_device_\(device)")
}
}
}
- 这个测试就会生成以下截图: