我们自己去啃这些开源源码是有一点难度的,这里介绍一本很好的书籍: Raywenderlich 的一本书 《Advanced iOS App Architecture》在介绍 MVVM 架构的时候,说到 Kickstarter 很彻底地遵循了 MVVM 架构。
首先第一感觉是代码非常令人爽心悦目,因为代码非常整洁。再仔细一看,发现里面有很多值得学习的地方,项目使用swift5.0编写,真正的使用MVVM架构模式,架构清晰,非常值得学习。
用到的第三方工具:
MVVM模式
ios git clone地址:https://github.com/kickstarter/ios-oss
android git clone地址:https://github.com/kickstarter/android-oss
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
Podfile
和 xcworkspace
文件。然而,Kickstarter 不是用 Cocoapods
来管理第三方库的,而是使用 git submodule
。Carthage
来管理第三方库。找到一篇文章:对比Carthage和Cocoapods和Git submodule,描述了这三种工具的优缺点。ColorScript
和 StringsScript
。{
"apricot_600": "FFCBA9",
"cobalt_500": "4C6CF8",
"dark_grey_400": "9B9E9E",
"dark_grey_500": "656868",
"facebookBlue": "3B5998",
...
}
json
文件,交给开发者,开发者用脚本生成 Colors.swift
,就一切都搞定了(如果颜色名字有变动或有新添加的颜色,还是需要开发者手动更改和添加)。如果不通过这种方法去做,而是开发者自己手动去写,那么可能会经常去手动修改 Colors.swift
,这样就麻烦一些。NSLocalizedString("Hello_World", comment: "")
去获取对应的本地化字符串,这种写法非常麻烦,而且很容易出错。StringsScript
把 Localizable.strings
转换生成 Strings.swift
文件,然后我们在使用的时候,就可以像这样去获取想要的字符串 Strings. Hello_World()
。这个脚本把 key 变成了方法名,让我们避免了在使用的时候出现错误,而且使用起来非常方便。Unit Test
和 UI Test
。KsApi
、Library
和 LiveStream
这三个文件夹不是存放在 Kickstarter-iOS文件夹里面的,而是跟它处于同一个目录。因为这三个文件夹存放的是独立于 Kickstarter-iOS 之外的 frameworkMVVM架构思想
这里有一个讲解 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
中。
UIView
,Kickstarter 通过扩展重写 awakeFromNib()
,在内部调用 bindViewModel()
。代码如下:extension UIView {
open override func awakeFromNib() {
super.awakeFromNib()
self.bindViewModel()
}
@objc open func bindViewModel() {
}
}
awakeFromNib()
会被调用,从而 bindViewModel()
也被调用。那么在其他继承自 UIView
的 view
中,只需要重写 bindViewModel()
,就能达到绑定 ViewModel
的目的。在 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() {
}
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UIViewController.doBadSwizzleStuff()
}
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
是被 UIView
和 UIViewController
持有,而持有 ViewModel 的对象就需要绑定到 ViewModel
,这样就能响应 ViewModel
中数据的变化,从而更新 UI。一般我们都会在持有 ViewModel
的对象中定义一个方法 bindViewModel()
,并且在这个方法里面做绑定。UIView
和 UIViewController
做了一些处理,让程序在启动的时候就默认在各自内部的方法调用了 bindViewModel()
,这样可以避免在很多的 View
和 ViewController
中写重复的代码。ViewModel
中,我们需要接受外部的信息输入,并且告诉外部有哪些信息发生了变化。HelpWebViewModelInputs
和 HelpWebViewModelOutputs
分开,这样在使用 ViewModel
的时候就会非常清晰,不会把 inputs
和 outputs
混在一起。例如,我们在Xcode 中编写 viewModel.outputs
. 时,Xcode 只会提示 webViewLoadRequest
,而不会把属于 inputs
的 viewDidLoad()
也显示给我们。ViewModel
的时候带来了极大的便利,并且让看代码的人一目了然,哪些代码处理输入,哪些代码处理输出,非常清晰。Environment
和 AppEnvironment
就是干这事的。public let apiService: ServiceType
public let cookieStorage: HTTPCookieStorageProtocol
public let device: UIDeviceType
public let ubiquitousStore: KeyValueStoreType
public let userDefaults: KeyValueStoreType
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()
,实在是非常方便。Environment
中,可以了解到 Service
是处理应用中所有网络请求的。进入到 Service, 这里编写了所有的网络请求方法。再仔细看,你会发现很多请求是通过类似 request(.facebookConnect(facebookAccessToken: token))
去调用的。我们就先来看看这个 request()
方法的参数 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
属性,获取到请求参数,接着做进一步的网络请求。Service+RequestHelpers.swift
。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
}
Universal Link
、URL Scheme
和 Push Notification
等方式跳转到应用的某一个页面。我们来看一下 Kickstarter-iOS 是怎么处理的。Navigation.swift
,跟网络请求一样,也是用 enum
定义了所有用户去往的目标页面。deep linking
传入的 url
来最终得到 Navigation
其中的一个 case
,然后跳转到目标页面呢?allRoutes: [String: (RouteParams) -> Decoded]
保存了所有的 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
}
}
}
以前,我们经常看到开发者们在争论:对于 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?
}
png
格式的图标。而在 Kickstarter 中,使用的是 pdf
格式的图标。我们先来看下 pdf
格式的图标有什么优点?pdf
格式的图标最大的优点是可以无损放大。还有,只需要一个 pdf 文件就可以代表一个图标,而png 图片一般至少需要两个(2x和 3x, 1x 一般不需要了)。除了这两个优点之外,我还发现 Kickstarter 中的 pdf 文件的大小只有 5k左右;而我们现有的项目中一个 png 图片就有 15k左右,两个 png 就 30k了,所以,使用 pdf 图片还可以一定程度上减少应用的大小。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)
}
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(...) # 具体看文件
internal func combos<A, B>(_ xs: [A], _ ys: [B]) -> [(A, B)] {
return xs.flatMap { x in
return ys.map { y in
return (x, y)
}
}
}
internal func traitControllers(device: Device = .phone4_7inch,
orientation: Orientation = .portrait,
child: UIViewController = UIViewController(),
additionalTraits: UITraitCollection = .init(),
handleAppearanceTransition: Bool = true)
-> (parent: UIViewController, child: UIViewController)
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)")
}
}
}