美文网首页
开源项目源码分析(Kickstarter-iOS )(一)

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

作者: 孔雨露 | 来源:发表于2019-08-25 19:39 被阅读0次

    @[TOC](开源项目源码分析(Kickstarter-iOS )(一))

    1.Kickstarter开源项目简介

    我们自己去啃这些开源源码是有一点难度的,这里介绍一本很好的书籍: Raywenderlich 的一本书 《Advanced iOS App Architecture》在介绍 MVVM 架构的时候,说到 Kickstarter 很彻底地遵循了 MVVM 架构。

    • 首先第一感觉是代码非常令人爽心悦目,因为代码非常整洁。再仔细一看,发现里面有很多值得学习的地方,项目使用swift5.0编写,真正的使用MVVM架构模式,架构清晰,非常值得学习。

    • 下面看几张项目架构图片:


      KickStart项目架构
    • 用到的框架:


      Kickstarter用到的框架
    • 用到的第三方工具:

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

    Kickstarter 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 下来之后,我们确实会发现文件夹里面没有我们常用的 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 独立框架
    • 这么做的好处当然是代码可以复用。目前我看 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<Navigation>] 保存了所有的 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 中测试。


      image

    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)")
          }
        }
    }
    
    • 这个测试就会生成以下截图:


      image

    相关文章

      网友评论

          本文标题:开源项目源码分析(Kickstarter-iOS )(一)

          本文链接:https://www.haomeiwen.com/subject/ksekectx.html