开源项目源码分析(Kickstarter-iOS )(一)

开源项目源码分析(Kickstarter-iOS )(一)

  • 1.Kickstarter开源项目简介
  • 2. Kickstarter项目结构
    • 2.1 Makefile 文件
    • 2.2 Git submodule
    • 2.3 脚本工具
      • 2.3.1 ColorScript脚本
      • 2.3.2 StringsScript脚本
    • 2.4 测试工具
    • 2.5 独立的代码库
  • 3. Kickstarter项目MVVM架构
    • 3.1 MVVM架构思想简介
    • 3.2 MVVM架构实际运用
      • 3.2.1 使用 [ReactiveSwift](https://github.com/ReactiveCocoa/ReactiveSwift)
      • 3.2.2 UIView
      • 3.2.3 UIViewController
      • 3.2.4 ViewModel
      • 3.2.5 Model
      • 3.2.6 bindViewModel()
      • 3.2.7 使用 inputs 和 outputs 区分数据的输入和输出
      • 3.2.8
    • 3.3 Environment
      • 3.3.1 Environment
      • 3.3.2 AppEnvironment
    • 3.4 网络请求的处理
      • 3.4.1 Service+RequestHelpers
      • 3.4.2 Deep Linking
    • 3.5 用 Storyboard / Xib 创建 UI
    • 3.6 PDF 格式的图标
    • 3.7 单元测试
      • 3.7.1 Model
      • 3.7.2 ViewModel
      • 3.7.3 UI 测试

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架构模式,架构清晰,非常值得学习。

  • 下面看几张项目架构图片:
    开源项目源码分析(Kickstarter-iOS )(一)_第1张图片

  • 用到的框架:
    开源项目源码分析(Kickstarter-iOS )(一)_第2张图片

  • 用到的第三方工具:

    • CircleCI:是一个持续集成的持续部署的工具,可以让开发者们更容易、更快地构建、测试和部署应用程序。
    • SwiftLint:一个检查 Swift 代码风格的工具,这可以说是 Swift 开发必备的工具。使用方法大家可以查看文档。
    • fastlane: 是一个开源平台,旨在简化 Android 和 iOS 的部署。他可以让我们自动化开发和发布的工作流程。
  • MVVM模式

开源项目源码分析(Kickstarter-iOS )(一)_第3张图片

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 下来之后,我们确实会发现文件夹里面没有我们常用的 Podfilexcworkspace 文件。然而,Kickstarter 不是用 Cocoapods 来管理第三方库的,而是使用 git submodule
  • 其实除了上面提到的两个管理第三方框架的工具之后,还可以用 Carthage 来管理第三方库。找到一篇文章:对比Carthage和Cocoapods和Git submodule,描述了这三种工具的优缺点。
  • 至于选择哪一种,就看我们更看重的是什么了。我一般都是使用的cocoapods.

2.3 脚本工具

  • 在根目录下的 bin 目录,我们可以看到两个用 Swift 编写的脚本:ColorScriptStringsScript

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 中,开发者用 StringsScriptLocalizable.strings转换生成 Strings.swift 文件,然后我们在使用的时候,就可以像这样去获取想要的字符串 Strings. Hello_World()。这个脚本把 key 变成了方法名,让我们避免了在使用的时候出现错误,而且使用起来非常方便。
  • 如果有做本地化的项目,采用这种方法可以给开发者带来很大的便利。

2.4 测试工具

  • 测试,是软件开发中非常重要的一个环节。甚至有些公司执行 TDD (测试驱动开发(Test-Driven Development)),可以见测试的重要性。
  • 在 Kickstarter-iOS 中,我们可以看到大量的 xxxTests.swift文件,包括了 Unit TestUI Test

2.5 独立的代码库

  • 用 Xcode 打开 Kickstarter-iOS 的项目,你会发现 KsApiLibraryLiveStream这三个文件夹不是存放在 Kickstarter-iOS文件夹里面的,而是跟它处于同一个目录。因为这三个文件夹存放的是独立于 Kickstarter-iOS 之外的 framework

开源项目源码分析(Kickstarter-iOS )(一)_第4张图片

  • 这么做的好处当然是代码可以复用。目前我看 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() 也被调用。那么在其他继承自 UIViewview 中,只需要重写 bindViewModel(),就能达到绑定 ViewModel 的目的。

3.2.3 UIViewController

  • UIViewController 中就会稍微复杂一点。Kickstarter 通过 runtime,默认在 viewDidLoad() 中调用 bindViewModel()。那么在其他继承自 UIViewControllerViewController 中,只需要重写 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 是被 UIViewUIViewController 持有,而持有 ViewModel 的对象就需要绑定到 ViewModel,这样就能响应 ViewModel 中数据的变化,从而更新 UI。一般我们都会在持有 ViewModel 的对象中定义一个方法 bindViewModel(),并且在这个方法里面做绑定。
  • Kickstarter 分别在 UIViewUIViewController 做了一些处理,让程序在启动的时候就默认在各自内部的方法调用了 bindViewModel(),这样可以避免在很多的 ViewViewController 中写重复的代码。

3.2.7 使用 inputs 和 outputs 区分数据的输入和输出

  • ViewModel 中,我们需要接受外部的信息输入,并且告诉外部有哪些信息发生了变化。
  • Kickstarter-iOS 把信息的输入和输出分别用 HelpWebViewModelInputsHelpWebViewModelOutputs 分开,这样在使用 ViewModel 的时候就会非常清晰,不会把 inputsoutputs 混在一起。例如,我们在Xcode 中编写 viewModel.outputs. 时,Xcode 只会提示 webViewLoadRequest,而不会把属于 inputsviewDidLoad()也显示给我们。
  • 这在我们使用 ViewModel 的时候带来了极大的便利,并且让看代码的人一目了然,哪些代码处理输入,哪些代码处理输出,非常清晰。

3.2.8

3.3 Environment

  • 有经验的 iOS 开发者应该都知道,在开发过程中我们需要设计一些对象来存储应用的全局状态,例如当前的登录用户等等。而在 Kickstarter-iOS 中,EnvironmentAppEnvironment 就是干这事的。

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 上进行业务逻辑的处理,而这明显是不合理的。
  • 如果你在项目中全局搜索 pushEnvironmentpopEnvironment,你会发现,这两个方法都是在测试文件中被调用,说明这两个方法是为测试而生的。
  • 另外 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 LinkURL SchemePush Notification 等方式跳转到应用的某一个页面。我们来看一下 Kickstarter-iOS 是怎么处理的。
  • 打开 Navigation.swift ,跟网络请求一样,也是用 enum 定义了所有用户去往的目标页面。
  • 那在 Kickstarter-iOS 中,它是怎样通过 deep linking 传入的 url 来最终得到 Navigation 其中的一个 case,然后跳转到目标页面呢?
  • 首先,它用一个字典 allRoutes: [String: (RouteParams) -> Decoded] 保存了所有的 routes:其中 keyurl 的模板;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 还定义了 StoryboardNib 枚举,列举了所有的 Storyboardxib 文件,方便 ViewControllerView 的初始化,这是一个非常漂亮的处理(以下代码省略了方法的具体实现)

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 pushstack 中作为当前的 Environment,执行完 body()后,再把刚刚 pushEnvironment 移除,这样可以保证不改变测试前后的 Environment
func withEnvironment(_ env: Environment, body: () -> Void) {
    AppEnvironment.pushEnvironment(env)
    body()
    AppEnvironment.popEnvironment()
}

func withEnvironment(...) # 具体看文件
  • 基本上每一个 Model 都会定义一个 template 实例,用于在 ViewModel 中测试。
    开源项目源码分析(Kickstarter-iOS )(一)_第5张图片

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)")
      }
    }
}
  • 这个测试就会生成以下截图:
    开源项目源码分析(Kickstarter-iOS )(一)_第6张图片

你可能感兴趣的:(开源项目)