@[TOC](开源项目源码分析(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, ReactiveSwiftReactiveSwift 是一个响应式编程的库,与 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)")
}
}
}
-
这个测试就会生成以下截图: