iOS开发进阶

Ruby工具链

目前流行的第三方工具 CocoaPodsfastlane 都是使用 Ruby 来开发的。特别是 Ruby 有非常成熟的依赖库管理工具 RubyGemsBundler,其中 Bundler 可以帮我们有效地管理 CocoaPodsfastlane 的版本。

Ruby{工具{CodoaPodsfastlane版本管理{RVMrbenv包管理{BundlerRubyGems\tt Ruby \begin{cases} \small 工具 \begin{cases} \tt CodoaPods \\[2ex] \tt fastlane \end{cases} \\ \small 版本管理 \begin{cases} \tt RVM \\[2ex] \tt rbenv \end{cases} \\ \small 包管理 \begin{cases} \tt Bundler \\[2ex] \tt RubyGems \end{cases} \\ \end{cases}
  • 推荐使用 rbenv,因为它使用 shims 文件夹来分离各个 Ruby 版本,相对于 RVM 更加轻装而方便使用。
  • 配置
# PATH
export PATH="$HOME/.rbenv/bin:$PATH" 
eval "$(rbenv init -)"

# install version
$ cd $(PROJECT_DIR)  # 这意思是每个项目装一个?
$ rbenv install 2.7.1
$ rbenv local 2.7.1
  • 以上生成一个.ruby-version文件,里面只有一个版本号。

RubyGems 和 Bundler

  • 在 Ruby 的世界,包叫作 Gem,我们可以通过gem install命令来安装。
  • 但是 RubyGems 在管理 Gem 版本的时候有些缺陷,就有人开发了 Bundler
  • Bundler本身也是一个包,所以要通过gem install bundler来安装
  • bundler init来初始化
  • 内容是你在CocoaPods里面熟悉的语法(即ruby)
source "https://rubygems.org"
gem "cocoapods", "1.10.0"
gem "fastlane", "2.166.0"

整合起来:

# Install ruby using rbenv
ruby_version=`cat .ruby-version`
if [[ ! -d "$HOME/.rbenv/versions/$ruby_version" ]]; then
  rbenv install $ruby_version;
fi
# Install bunlder
gem install bundler
# Install all gems
bundle install
# Install all pods
bundle exec pod install
  1. 安装正确的 ruby 版本
  2. 用 ruby gem 安装 bundle
  3. 用 bundle 安装 cocoapods
  4. 用 cocoapods 安装项目依赖

CocoaPods

source 'https://cdn.cocoapods.org/' # 公共库
source 'https://my-git-server.com/internal-podspecs' # 私有库

project './Moments/Moments.xcodeproj' # 项目文件
workspace './Moments.xcworkspace'  # 针对项目文件生成工作空间,包含了下载回来的依赖,还会生成一个 Pods.xcodeproj 项目,都是平级

platform :ios, '14.0' # 最低支持iOS版本
use_frameworks! # 把依赖库打包成静态库还是动态库

# 组织依赖的一种方案
# 比如根据是否是开发中才需要使用的
# 还有根据用户名来组织的,这样每个开发维护自己的依赖,在开发阶段不会冲突(在依赖很多的时候,每个开发只开发其中部分模板,其它模板会需要设成无端获取,这样每个人的需求是不一样的
def dev_pods
  pod 'SwiftLint', '0.40.3', configurations: ['Debug']
  pod 'SwiftGen', '6.4.0', configurations: ['Debug'] # 只在debug的构建下使用
end

# 最终是在target里面组装的
target 'Moments' do
  dev_pods
  core_pods
  # other pods...
end

# 使用本地路径
pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false

版本比较

  • = 只安装这个版本
  • > 0.1表示大于 0.1 的任何版本,这样可以包含 0.2 或者 1.0;
  • >= 0.1表示大于或等于 0.1 的任何版本;
  • < 0.1表示少于 0.1 的任何版本;
  • <= 0.1表示少于或等于 0.1 的任何版本;
  • ~> 0.1.2表示大于 0.1.2 而且最高支持 0.1.* 的版本,但不包含 0.2 版本。

这几个操作符相里面,~>(Squiggy arrow)操作符更为常用,既保持了小更新,也没有跨大版本,避免了api的变更

Podfile.lock文件

DEPENDENCIES:
  - Alamofire (= 5.2.0)
  - Firebase/Analytics (= 7.0.0)
PODFILE CHECKSUM: 400d19dbc4f5050f438797c5c6459ca0ef74a777
  • 所有依赖库的版本号都参与了PODFILE CHECKSUM的计算
  • 所以如果要严格保证每一个小版本都团队内一致的话,这个文件可以提交到git
    • 这样只要文件一冲突,必然知道要么是依赖数量对不上,要么是版本对不上

Workspace

  • 通过 Workspace,我们可以把相关联的多个 Xcode 子项目组合起来方便开发。
    • 原生项目,加上Cocoapods生成的Pods.xcodeproj ,至少就有两个项目了,所以需要一个Workspace来管理
  • CocoaPods 还会修改 Xcode 项目中的 Build Phases  以此来检测 Podfile.lock 和 Manifest.lock 文件的一致性,并把Pods_<项目名称>.framework动态库嵌入我们的主项目中去。

Pod 版本更新

  • CocoaPods 已经为我们提供了pod outdated命令,我们可以用它一次查看所有 Pod 的最新版本,而无须到 GitHub 上逐一寻找。
  • 千万不要使用pod update,因为pod update会自动把开发者机器上所有 Pod 的版本自动更新了。

作者推荐的是每用pod outdated发现一个更新,都要阅读更新文档,确定有没有需要改代码的地方,以及所有用到相关api的地方复测一遍,然后再把本地修改并确认好的podfile文件和lock文件上传到远端,供团队其他成员同步。(所以他认为不加验证地直接update是危险的)

多环境支持

Xcode 构建基础概念

  • 一般在构建一个 iOS App 的时候,需要用到 Xcode Project,Xcode Target,Build Settings,Build Configuration 和 Xcode Scheme 等构建配置。
  • Xcode Project用于组织源代码文件和资源文件。
    • 一个 Project 可以包含多个 Target
    • 例如当我们新建一个 Xcode Project 的时候,它会自动生成 App 的主 Target,Unit Test Target 和 UI Test Target。
  • Xcode Target用来定义如何构建出一个产品(例如 AppExtension 或者 Framework
    • Target 可以指定需要编译的源代码文件和需要打包的资源文件,以及构建过程中的步骤。
    • 那么 Target 所指定的设置哪里来的呢?来自 Build Settings。
  • Build Setting保存了构建过程中需要用到的信息,它以一个个变量(键值对)的形式而存在,例如所支持的设备平台,或者支持操作系统的最低版本等。

推荐使用 Build ConfigurationXcode Scheme 来管理多环境,进而构建出不同环境版本的 App。

Build Configuration

  • Build Configuration就是一组 Build Setting。 我们可以通过 Build Configuration 来分组和管理不同组合的 Build Setting 集合,然后传递给 Xcode 构建系统进行编译。
  • 当我们在 Xcode 上新建一个项目的时候,Xcode 会自动生成两个 Configuration:Debug和Release。
  • 怎样在构建过程中选择不同的configuration呢?Xcode Scheme
  • Xcode Scheme用于定义一个完整的构建过程,其包括指定哪些 Target 需要进行构建,构建过程中使用了哪个 Build Configuration ,以及需要执行哪些测试案例等等。
  • 在项目新建的时候只有一个 Scheme,但可以为同一个项目建立多个 Scheme。
  • 为了方便管理,我们通常的做法是,一个 Scheme 对应一个 Configuration。有了这三个 Scheme 以后,我们就可以很方便地构建出 Moments α(开发环境),Moments β(测试环境)和 Moments(生产环境)三个功能差异的 App。

  • 要实现上面三个环境打包出不同的名字,从这个路径,就可以为每个configuration设置不同的product name
  • 这里也同样可以知道如果搜索环境变量,如"$(PRODUCT_NAME)"
  • 但其实显示名用的是info.plist文件里的bundle display name字段(默认与bundle name指向的都是product name),这个字段目前没看到可以为不同的configuration而设置

以上为不同环境打不同包的做法,虽然直观,但是不适用在开发/测试阶段可以切换环境的方案,所以要自己团队沟通选择什么路线。

xcconfig配置文件

一般修改 Build Setting 的办法是在 Xcode 的 Build Settings 界面上进行,这样做有一些不好的地方

  • 首先是手工修改很容易出错,例如有时候很难看出来修改的 Setting 到底是 Project 级别的还是 Target 级别的。 其次,最关键的是每次修改完毕以后都会修改了 xcodeproj 项目文档 (如下图所示),导致 Git 历史很难查看和对比。

  • Xcode 为我们提供了一个统一管理这些 Build Setting 的便利方法,那就是使用 xcconfig 配置文件来管理。

  • xcconfig也叫作 Build configuration file(构建配置文件),我们可以使用它来为 Project 或 Target 定义一组 Build Setting。

  • 它是一个纯文本文件,我们可以使用 Xcode 以外的其他文本编辑器来修改,而且可以保存到 Git 进行统一管理。

  • 格式就是键值对

当我们使用 xcconfig 时,Xcode 构建系统会按照下面的优先级来计算出 Build Setting 的最后生效值:

  • Platform Defaults (平台默认值)

  • Xcode Project xcconfig File(Project 级别的 xcconfig 文件)

  • Xcode Project File Build Settings(Project 级别的手工配置的 Build Setting)

  • Target xcconfig File (Target 级别的 xcconfig 文件)

  • Target Build Settings(Target 级别的手工配置的 Build Setting)

  • 所以为了避免互相冲突(覆盖),在添加值时参考这种用法

FRAMEWORK_SEARCH_PATHS = $(inherited) ./Moments/Pods #或 $(PROJECT_DIR) 这是引入别的Build Setting
  • 重用,引用其它xcconfig文件
#include "path/to/OtherFile.xcconfig"

一个应用实践(先不说是否过度设计):

我们把所有 xcconfig 文件分成三大类:Shared、 Project 和 Targets。

其中 Shared 文件夹用于保存分享到整个 App 的 Build Setting,例如 Swift 的版本号、App 所支持的 iOS 版本号等各种共享的基础信息。

下面是 SDKAndDeviceSupport.xcconfig 文件里面所包含的信息:

TARGETED_DEVICE_FAMILY = 1
IPHONEOS_DEPLOYMENT_TARGET = 14.0
  • TARGETED_DEVICE_FAMILY表示支持的设备,1表示 iPhone。
  • IPHONEOS_DEPLOYMENT_TARGET表示支持 iOS 的最低版本。

Project 文件夹用于保存 Xcode Project 级别的 Build Setting,其中 BaseProject.xcconfig 会引入 Shared 文件夹下所有的 xcconfig 配置文件,如下所示:

#include "CompilerAndLanguage.xcconfig"
#include "SDKAndDeviceSupport.xcconfig"
#include "BaseConfigurations.xcconfig"

然后我们会根据三个不同的环境分别建了三个xcconfig 配置文件,如下:

DebugProject.xcconfig 文件

#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG

InternalProject.xcconfig 文件

#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) INTERNAL

AppStoreProject.xcconfig 文件

#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PRODUCTION
  • 它们的共同点是都引入了用于共享的 BaseProject.xcconfig 文件,然后分别定义了 Swift 编译条件配置SWIFT_ACTIVE_COMPILATION_CONDITIONS。
  • 其中(inherited)表示继承原有的配置,(inherited)表示继承原有的配置,(inherited)后面的DEBUG或者INTERNAL表示在原有配置的基础上后面添加了一个新条件。

有了这些编译条件,我们就可以在代码中这样使用:

#if DEBUG
    print("Debug Environment")
#endif

该段代码只在开发环境执行,因为只有开发环境的SWIFT_ACTIVE_COMPILATION_CONDITIONS才有DEBUG的定义。

Targets 文件夹用于保存 Xcode Target 级别的 Build Setting,也是由一个 BaseTarget.xcconfig 文件来共享所有 Target 都需要使用的信息。

PRODUCT_BUNDLE_NAME = Moments

这里的PRODUCT_BUNDLE_NAME是 App 的名字。 下面是三个不同环境的 Target xcconfig 文件。

DebugTarget.xcconfig

#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.debug.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited) α
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.development

InternalTarget.xcconfig

#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.internal.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited) β
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.internal

AppStoreTarget.xcconfig

#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.appstore.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited)
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments
  • 它们都需要引入 CocoaPods 所生成的 xcconfig 和共享的 BaseTarget.xcconfig 文件,
  • 然后根据需要改写 App 的名字。
  • 顺便还为每个configuration设置了独立的bundleid 一旦有了这些 xcconfig 配置文件,今后我们就可以在 Xcode 的 Project Info 页面里的 Configurations 上引用它们。 可以在 build Settings 页面来查看具体的生效值(all + level), 5列就对应的5个优先级(越往左越高,所以resolved就代表解析值的意思)

总结下

最后

  1. 如果选择用xcconfig来文本化配置,那么千万不要再在UI上来修改(会覆盖)
  2. 图标,URLScheme也可以通过 Build Configuration 来做。
  • 首先要在 Asset Catalog 加上新的 Icon set,例如命名为 AppIcon.beta (可以准备不同的图标)
  • 然后在不同的 xcconfig 里面配置 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon.beta 等值。

SwiftLint

安装 SwiftLint 的方式有很多种,例如使用 Homebrew,Mint,下载 SwiftLint.pkg 安装包等等。但我只推荐 CocoaPods 这一种方法,因为通过 CocoaPods 可以有效地管理 SwiftLint 的版本,从而保证团队内各个成员都能使用一模一样的 SwiftLint 及其编码规范。在Podfile文件里添加如下

pod 'SwiftLint', '= 0.41.0', configurations: ['Debug']

  • .swiftlint.yml主要用于启动和关闭 SwiftLint 所提供的规则,以及自定义配置与规则。
  • SwiftLint 提供了disabled_rules,opt_in_rules和only_rules三种规则设置方法。
    • disabled_rules能帮我们关闭默认生效的规则,
    • opt_in_rules可以启动默认关闭的规则。
    • 但我不推荐你使用它们 ,而是用only_rules来定义每条生效的规则。(这样不怕不同版本的默认规则有区别)

demo:

only_rules:
  - array_init
  - attributes
  - block_based_kvo
  - class_delegate_protocol
  - closing_brace

自定义配置:

line_length: 110
file_length:
  warning: 500
  error: 1200

自定义规则:

custom_rules:
  no_hardcoded_strings:
    regex: "([A-Za-z]+)"
    match_kinds: string
    message: "Please do not hardcode strings and add them to the appropriate `Localizable.strings` file; a build script compiles all strings into strongly typed resources available through `Generated/Strings.swift`, e.g. `L10n.accessCamera"
    severity: warning

该规则no_hardcoded_strings会通过正则表达式来检查字符串是否进行了硬编码。

排除扫描路径:

excluded:
  - Pods

Fastlane

Git

  • 建立一个模板文件pull_request_template.md。当我们提交 PR 的时候,GitHub 会自动读取并准备好描述文档的模板,我们只需要填写相关内容即可。
## Summary

- Github issue/doc: _link_
- Card: _link_

_A clear and concise description of this PR. e.g. Adding Link button to moments screen_

## Details
### Description
_Long description of this PR._
  - _Why are we doing this?_
  - _Any other related context_

### Screengrabs (if applicable)

_Optional but highly recommended._

| Before | After |
| - | - |
| _before_ | _after_ |

## Quality Analysis

- [ ] Unit tests that cover all added and changed code
- [ ] Tested on Simulator
- [ ] Tested on iPhone
- [ ] Tested on iPad (if applicable)

**Testing steps:**

0. _Step 1_
0. _Step 2_
0. _..._

## Checklist

* [ ] Has feature toggling been considered?
* [ ] Has tested both dark mode and light mode if there is any UI change?
* [ ] Has tested Dynamic Type if there is any UI change?
* [ ] Has tested language support for multiple locales if there is any UI change?
* [ ] Have new test cases been unit tested? 
* [ ] Have run `bundle exec fastlane prepare_pr`?
* [ ] Need to labelled the PR? (If applicable: e.g. added new dependencies etc.)

统一的设计规范

间距

  • 只保留几个几个命名间距,如下面是开源设计规范 Backpack 所定义的间距,其包含了 iOS、 Android 和 Web 三个平台。
// in DesignKit
public struct Spacing {
    public static let twoExtraSmall: CGFloat = 4
    public static let extraSmall: CGFloat = 8
    public static let small: CGFloat = 12
    public static let medium: CGFloat = 18
    public static let large: CGFloat = 24
    public static let extraLarge: CGFloat = 32
    public static let twoExtraLarge: CGFloat = 40
    public static let threeExtraLarge: CGFloat = 48
}

// use
import DesignKit

private let likesStakeView: UIStackView = configure(.init()) {
    $0.spacing = Spacing.twoExtraSmall
    $0.directionalLayoutMargins = NSDirectionalEdgeInsets(
      top: Spacing.twoExtraSmall, 
      leading: Spacing.twoExtraSmall, 
      bottom: Spacing.twoExtraSmall, 
      trailing: Spacing.twoExtraSmall)
  }

字体

  • iOS 的 App 一般都使用 iOS 系统所自带的字体系列。这样更能符合用户的阅读习惯。在自带的字体系列的基础上,通过把字号大小和字体粗细组合起来定义一些字体类型。

颜色

  • 为了给用户提供颜色一致的体验,在 App 设计中,我们一般采用统一的调色板(Color palette)来完成。
  • 如果你所在团队没有专门的设计师来定义这些颜色,也可以使用 iOS 提供的动态系统颜色(Dynamic System Colors),它为我们定义了同时支持浅色和深色模式的各种颜色。
  • 在定义语义化颜色时要特别注意颜色之间的对比度,例如使用了Text Primary Color的文本在使用Background Color的背景下能容易阅读,而使用灰色的背景再使用黑色的文本会难以阅读。

图标

  • 如果没有特殊要求,我推荐直接使用苹果公司提供的。具体来说,在 iOS 系统内置的 SF Symbols 为我们提供了 3150 个一致的、可定制的图标
  • SF Symbols 里绝大部分的图标都有轮廓和填充两个版本,我们可以使用填充的图标表示选中状态。

常用组件

  • 一些会重复出现的UI组件,可以纳入设计规范,避免场景不同了设计也变了。
  • 但这是个使用中逐渐发现并归纳的过程,也要避免一开始就贪多直接定义一堆不用的组件

所以设计规范需要开发团队与设计师一起参进进来,提前约定。

组件库实例

  • 在实际操作中,我们一般先创建内部库,如果今后有必要,可以再升级为私有库乃至开源库。
  • 我们通过 CocoaPods 创建和管理这个内部库,有两种方法(命令/手动)
  1. pod lib create [pod name], 如DesignKit 会生成如下文件:
DesignKit
  Example/   
  README.md
  DesignKit.podspec
  LICENSE           
  _Pods.xcodeproj
  • 会自动包含一个工程和一个示例项目,
  • podspec文件关注几个设置,主要是版本和路径相关
  1. 手动创建文件
DesignKit        
  DesignKit.podspec
  assets/
  src/
  LICENSE        
  • pod spec lint命令检测spec文件正确性
  • 使用路径指定内部库
pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false
  • pod install后:

功能开关组件

  • 如果一个大功能,直接做一个分支管理开发过程既漫长,也无法细粒度地维护,可以把它拆成很多小功能,依次提交
  • 但是会有一个问题,几个小功能提交后碰到一次发布的话,会把主体未开发完的小功能也发布了上去,因此可以考虑对功能进行“开关”
    • 先做个开关,直到开发完毕再移除它,或者在所有代码合并完毕后,从主分支再拉一个remove-toggle这样的分支,专门用来移除它。
    • 当然,把小功能往一个主功能分支提也是个思路,非本节内容
{编译时开关本地开关远程开关\begin{cases} 编译时开关 \\ 本地开关 \\ 远程开关 \end{cases}

实现思路:

  • 做一个开关协议,最终实现三种开关,每种开关根据自己的特征开放接口
  • 做一个开关的行为类,主要实现判断和更新
    • 比如使用本地编译标识的话,"更新"方法就不需要实现了
struct BuildTargetTogglesDataStore: TogglesDataStoreType {
    static let shared: BuildTargetTogglesDataStore = .init()
    private let buildTarget: BuildTargetToggle
    private init() {
        #if DEBUG
        buildTarget = .debug
        #endif
        #if INTERNAL
        buildTarget = .internal
        #endif
        #if PRODUCTION
        buildTarget = .production
        #endif
    }
    func isToggleOn(_ toggle: ToggleType) -> Bool {
        guard let toggle = toggle as? BuildTargetToggle else {
            return false
        }
        return toggle == buildTarget
    }
    func update(toggle: ToggleType, value: Bool) { }
}

这里是直接读编译变量,前面说过这些是你设置的,来自 InternalProject.xcconfig 文件:

SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) INTERNAL

在任何地方,都使用这些toggle的datastore来先判断再执行:

extension UIWindow {
    override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        if BuildTargetTogglesDataStore.shared.isToggleOn(BuildTargetToggle.debug)
            || BuildTargetTogglesDataStore.shared.isToggleOn(BuildTargetToggle.internal) {
            let router: AppRouting = AppRouter()
            if motion == .motionShake {
                router.route(to: URL(string: "\(UniversalLinks.baseURL)InternalMenu"), from: rootViewController, using: .present)
            }
        }
    }
}

所有功能开关都需要及时清理,只要不是付费隐藏内容之类的设计,多余的判断都是没必要的。

使用累进合并master而不是一个巨大的功能分支的好处:

  • 有了开关就可以把未完成的功能都合并到 master,不断快速迭代。master 也可以随时发布。
  • 只有内部测试人员在打开开关的时候才能看到开发中的功能。
  • 如果没有功能开关,功能分支可能变得很大很长,合并时可能会有很多冲突,
  • 而且由于不在 master 里面,不好通过 CI 自动化,测试人员也不容易得到测试包。

SwiftGen

作用是用常量的方式来使用资源字符串(如图片,多语言code等)

pod 'SwiftGen', '= 6.4.0', configurations: ['Debug']

这里要注意,由于我们自己的源代码会使用到 SwiftGen 所生成的代码,因此必须把 Run SwiftGen 步骤放在 Compile Source 步骤之前。

strings:
  inputs:
    - Moments/Resources/en.lproj
  outputs:
    - templateName: structured-swift5
      output: Moments/Generated/Strings.swift
  1. 表示这是个strings的任务
  2. 来源是xxx/en.lproj (因为多语言code是一样的,所以指定一个就好了)
  3. 后面就是模板文件和输出位置,模板文件是需要自己多留意一下的。

生成类似的代码:

internal enum L10n {
  internal enum InternalMenu {
    /// Area 51
    internal static let area51 = L10n.tr("Localizable", "internalMenu.area51")
    /// Avatars
    internal static let generalInfo = L10n.tr("Localizable", "internalMenu.generalInfo")
  }
}
  1. 使用了a.b这样的用点分隔的code,好处理可以归类
  2. swiftgen能支持这种用法,并且把它当成了命名空间,直接做成了嵌套的枚举

使用:

let title = L10n.InternalMenu.area51
let infoSection = InternalMenuSection(
    title: L10n.InternalMenu.generalInfo,
    items: [InternalMenuDescriptionItemViewModel(title: appVersion)]
)

不再需要硬编码字符串了

类似的,有R.swift

动态字体和辅助功能

使用iOS自带的UIFont.UITextStyle

label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true

第三方字体要与UITextStyle建立关联:

guard let customFont = UIFont(name: "CustomFont", size: UIFont.labelFontSize) else {
    fatalError("Failed to load the "CustomFont" font. Make sure the font file is included in the project and the font name is spelled correctly."
    )
}
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true

可见,相比直接使用UIFont,改而使用了UIFontMetrics

深色模式和语义色(Semantic colors)

为了简化深色模式的适配过程,苹果公司提供了具有语义的系统色(System colors)和动态系统色(Dynamic system colors)供我们使用。 上图是苹果开发者网站提供的一个 iOS 系统色,有蓝色、绿色、靛蓝、橙色、黄色等,它们在浅色模式和深色模式下会使用到不同的颜色值。

上图显示是 iOS 系统提供的动态系统色的定义。它们都是通过用途来定义各种颜色的名称。例如 Label 用于主标签文字的颜色,而 Secondary label用于副标签文字的颜色,使用它们就能自动支持不同的外观模式了。

移动端系统架构的设计与实现

  • BFF(Backend for Frontend),即为前端服务的后端,不要求前端做一个个的原子请求,还要处理业务逻辑或异步返回的先后顺序,后端自行分发服务和组织顺序,最终整合数据统一返给前端。
  • 不同微服务(或不同的数据源,不同的第三方)可能有自己的连接方式,app就需要准备所有的SDK,而BFF只需要对接一个服务(这一个服务也要注意尽量为前端封装业务逻辑)
  • 多端开发行为一致,避免了不同的服务调用顺序和传参的差别造成体验不一致或出现不同的问题
  • 只需要公开一个服务到公网

GraphQL

和 REST API,gRPC 以及 SOAP 相比, GraphQL 架构有以下几大优点。

  • GraphQL 允许客户端按自身的需要通过 Query 来请求不同数据集,而不像 REST API 和gRPC 那样每次都是返回全部数据,这样能有效减轻网络负载。
  • GraphQL能减轻为各客户端开发单独 Endpoint 的工作量。比如当我们开发 App Clip 的时候,App Clip 可以在 Query 中以指定子数据集的方式来使用和主 App 相同的 Query,而无须重新开发新 Endpoint。
  • GraphQL 服务能根据客户端的 Query 来按需请求数据源,避免无必要的数据请求,减轻服务端的负载。

Apollo Server

Apollo Server 是基于 Node.js 的 GraphQL 服务器,目前非常流行。使用它,可以很方便地结合 Express 等 Web 服务,而且还可以部署到亚马逊 Lambda,微软 Azure Functions 等 Serverless 服务上。

限制:

GraphQL 通常使用 HTTP POST 请求,但有些 CDN (content delivery network,内容分发网络)对 POST 缓存支持不好,当我们把 GraphQL 的请求换成 GET 时,整个 Query 会变成 JSON-encoded 字符串并放在 Query String 里面进行发送。此时,要特别注意该 Query String 的长度不要超过 CDN 所支持的长度限制(比如 Akamai 支持最长的 URL 是 8892 字节),否则请求将会失败。

MVVM

其实就是由ViewController做所有脏活变成了由ViewModel做所有脏活

响应式编程与RxSwift

  • 所谓响应式编程,就是使用异步数据流(Asynchronous data streams)进行编程。
  • 在传统的指令式编程语言里,代码不仅要告诉程序做什么,还要告诉程序什么时候做。而在响应式编程里,我们只需要处理各个事件,程序会自动响应状态的更新。
  • 而且,这些事件可以单独封装,能有效提高代码复用性并简化错误处理的逻辑。
  • Android 平台的 Architecture Components 提供了支持响应式编程的 LiveData, SwiftUI 也配套了 Combine 框架
  • 除了RxSwift, 目前比较流行的响应式编程框架还有 ReactiveKitReactiveSwift 和 Combine
  • RxSwift 遵循了 ReactiveX 的 API 标准,由于 ReactiveX 提供了多种语言的实现,学会 RxSwift 能有效把知识迁移到其他平台。

  1. 当用户打开朋友圈页面,App 会使用后台排程器向 BFF 发起一个网络请求,
  2. Networking 模块把返回结果通过Observable 序列发送给 Repository 模块。
  3. Repository 模块订阅接收后,把数据发送到Subject里面,
  4. 然后经过map 操作符转换,原先的 Model 类型转换成了 ViewModel 类型。
  5. ViewModel 模块订阅经过操作符转换的数据,发送给下一个Subject,
  6. 之后,这个数据被 ViewController 订阅,并通过主排程器更新了 UI。

整个过程中,Repository 模块、 ViewModel模块、ViewController 都是订阅者,分别接收来自前一层的信息。就这样,当 App 得到网络返回数据时,就能自动更新每一层的状态信息,也能实时更新 UI 显示。

异步数据序列 Observable

  • 为了保证程序状态的同步,我们需要把各种异步事件都发送到异步数据流里,供响应式编程发挥作用。
  • 在 RxSwfit 中,异步数据流称为 Observable 序列,它表示可观察的异步数据序列,也可以理解为消息发送序列。
  • 在实际应用中,我们通常使用 Observable 序列作为入口,把外部事件连接到响应式编程框架里面。

那么怎样创建 Observable 序列呢?为方便我们生成 Observable 序列, RxSwfit 的Observable类型提供了如下几个工厂方法:

  1. just方法,用于生成只有一个事件的 Observable 序列;
  2. of方法,生成包含多个事件的 Observable 序列;
  3. from方法,和of方法一样,from方法也能生成包含多个事件的 Observable 序列,但它只接受数组为入口参数。
let observable1: Observable<Int> = Observable.just(1) // 序列包含 1
let observable2: Observable<Int> = Observable.of(1, 2, 3) // 序列包含 1, 2, 3 
let observable3: Observable<Int> = Observable.from([1, 2, 3]) // 序列包含 1, 2, 3
let observable4: Observable<[Int]> = Observable.of([1, 2, 3]) // 序列包含 [1, 2, 3]

// demo
let peopleObservable = Observable.of(
  Person(name: "Jake", income: 10), 
  Person(name: "Ken", income: 20)
  )

可以理解为from会解包,但of不会

订阅者

在响应式编程模式里,订阅者是一个重要的角色。在 RxSwift 中,订阅者可以调用Observable对象的subscribe方法来订阅。

let observable = Observable.of(1, 2, 3)
observable.subscribe { event in
    print(event)
}

// event的定义是:
public enum Event<Element> {
    /// Next element is produced.
    case next(Element)
    /// Sequence terminated with an error.
    case error(Swift.Error)
    /// Sequence completed successfully.
    case completed
}

所以会得到输出:

next(1)
next(2)
next(3)
completed

由于之前讲过的offrom等方法都不能发出errorcompleted事件 ,在这里我就使用了create方法来创建 Observable 序列。

Observable<Int>.create { observer in
    observer.onNext(1)
    // 发送completed的例子
    observer.onCompleted()
    observer.onNext(2)
    // 发送error事件的例子
    observer.onError(MyError.anError)
    observer.onNext(3)
    return Disposables.create()
}.subscribe { event in
    print(event)
}
  • subscribe方法返回的类型为Disposable的对象,我们可以通过调用该对象的dispose方法来取消订阅。
  • 取消订阅不是让消息源发送complete,仅仅是改变订阅者自己的行为
let disposable = Observable.of(1, 2).subscribe { element in
    print(element) // next event
} onError: { error in
    print(error)
} onCompleted: {
    print("Completed")
} onDisposed: {
    print("Disposed")
}
disposable.dispose()

加入delay

let disposableWithDelay = Observable.of(1, 2)
  .delay(.seconds(2), scheduler: MainScheduler.instance)
  .subscribe { element in
      print(element) // next event
  }
  ...
  disposableWithDelay.dispose()
  • 延迟的是“发送通知”的时间
  • 因为上述代码立即调用了dispose方法,所以在发送通知前已经取消订阅了,因为不会打印任何过程中的事件,只会直接打印dispose
  • 所以异步事件里用同步写法是做不到在监听事件本身结束后才取消订阅的,
  • RxSwift 为我们提供了DisposeBag类型,方便存放和管理各个Disposable对象。
    • 我的理解就是由信息源来解除订阅者的订阅。
let disposeBag: DisposeBag = .init()
Observable.just(1).subscribe { event in
    print(event)
}.disposed(by: disposeBag)
Observable.of("a", "b").subscribe { event in
    print(event)
}.disposed(by: disposeBag)

可见,是disposed方法多了个参数

上面subscrib有时候是Element有时候是Event,且都是位置参数0,自己试试区别。(直接subscribe就是Event, 用onNext等参数取出来就是Element)

以上是如何生成、订阅和退订 Observable 序列。

事件中转 Subject

  • 使用Observable的工厂方法所生成的对象都是“只读”,一旦生成,就无法添加新的事件。
  • 但很多时候,我们需要往 Observable 序列增加事件,比如要把用户点击 UI 的事件添加到 Observable 中,或者把底层模块的事件加工并添加到上层模块的序列中。
  • RxSwift 为我们提供的 Subject 及其onNext方法可以完成这项操作。
  • Subject作为一种特殊的 Observable 序列,它既能接收又能发送,我们一般用它来做事件的中转。
    • 用例: 比如,当 Repository 模块从 Networking 模块中接收到事件时,会把该事件转送到自身的 Subject 来通知 ViewModel,从而保证 ViewModel 的状态同步。
  • 常见的 Subject 一般有 PublishSubjectBehaviorSubject 和 `ReplaySubject。它们的区别在于订阅者能否收到订阅前的事件:
    • PublishSubject:如果你想订阅者只收到订阅后的事件,可以使用 PublishSubject。
    • BehaviorSubject:如果你想订阅者在订阅时能收到订阅前最后一条事件,可以使用 BehaviorSubject。
    • ReplaySubject:如果你想订阅者在订阅的时候能收到订阅前的 N 条事件,那么可以使用 ReplaySubject。
  • 而在订阅以后,它们的行为都是一致的,当 Subject 发出error或者completed事件以后,订阅者将无法接收到新的事件
  • 就后面的实例,是subscribe(Subject),即把subject传进subscribe里来实现中转

操作符

操作符(Operator)是 RxSwift 另外一个重要的概念,它能帮助订阅者在接收事件之前把 Observable 序列中的事件进行过滤、转换或者合并。

  • 转换:map
  • 过滤:filter, distinctUntilChanged
  • 合并:startWithconcatmergecombineLatestzip

除了上面提到过的常用操作符,RxSwift 还为我们提供了 50 多个操作符,那怎样才能学会它们呢?我推荐你到 rxmarbles.com 或者到 App Store 下载 RxMarbles App,然后打开各个操作符并修改里面的参数,通过输入的事件和执行的结果来理解这些操作的作用

排程器

  • 保持程序状态自动更新之所以困难,很大原因在于处理并发的异步事件是一件繁琐的事情。
  • 为了方便处理来自不同线程的并发异步事件,RxSwift 为我们提供了排程器。
    • 它可以帮我们把繁重的任务调度到后台排程器完成,
    • 并能指定其运行方式(如是串行还是并发),
    • 也能保证 UI 的任务都在主线程上执行。
  • 根据串行或者并发来归类,我们可以把排程器分成两大类串行的排程器并发的排程器
    • 串行的排程器包括 CurrentThreadScheduler、MainScheduler、SerialDispatchQueueScheduler。
      • CurrentThreadScheduler可以把任务安排在当前的线程上执行,这是默认的排程器。
      • MainScheduler是把任务调度到主线程MainThread里并马上执行,它主要用于执行 UI 相关的任务
      • SerialDispatchQueueScheduler则会把任务放在dispatch_queue_t里面并串行执行。
    • 并发的排程器包括 ConcurrentDispatchQueueScheduler 和 OperationQueueScheduler。
      • ConcurrentDispatchQueueScheduler把任务安排到dispatch_queue_t里面,且以并发的方式执行。
        • 该排程器一般用于执行后台任务,例如网络访问和数据缓存等等。
        • 在创建的时候,我们可以指定DispatchQueue的类型,例如使用ConcurrentDispatchQueueScheduler(qos: .background)来指定使用后台线程执行任务。
      • OperationQueueScheduler是把任务放在NSOperationQueue里面,以并发的方式执行。
        • 这个排程器一般用于执行繁重的后台任务,并通过设置maxConcurrentOperationCount来控制所执行并发任务的最大数量。
        • 它可以用于下载大文件。
Observable.of(1, 2, 3, 4)
    // 发布事件要执行的代码放到后台
    .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background))
    .dumpObservable()
    .map { "\(getThreadName()): \($0)" }
    // 订阅事件要执行的代码放到主线程
    .observeOn(MainScheduler.instance)
    .dumpObserver()
    .disposed(by: disposeBag)

这是常用的模式,比如网络请求在后台,拿到数据后更新UI肯定是在主线程

后台+异步,不能保证执行顺序,比如几个网络请求,你并不能保证一定最先处理哪一个的返回

经验:

  1. 当我们拿到需求的时候,先把任务进行分解,找出哪个部分是事件发布者,哪部分是事件订阅者,例如一个新功能页面,网络请求部分一般是事件发布者,当得到网络请求的返回结果时会发出事件,而 UI 部分一般为事件订阅者,通过订阅事件来保持 UI 的自动更新。
  2. 找到事件发布者以后,要分析事件发布的频率与间隔。如果只是发布一次,可以使用Obervable;如果需要多次发布,可以使用Subject;如果需要缓存之前多个事件,可以使用 ReplaySubject。
  3. 当我们有了事件发布者和订阅者以后,接着可以分析发送和订阅事件的类型差异,选择合适的操作符来进行转换。我们可以先使用本讲中提到的常用操作符,如果它们还不能解决你的问题,可以查看 RxMarbles 来寻找合适的操作符。
  4. 最后,我们可以根据事件发布者和订阅者所执行的任务性质,通过排程器进行调度。例如把网络请求和数据缓存任务都安排在后台排程器,而 UI 更新任务放在主排程器。

网络层架构

为了存取服务器上的数据,并与其他用户进行通信,几乎所有的 iOS App 都会访问后台 API 。目前流行的后台 API 设计有几种方案: RESTful、gRPC、GraphQL 和 WebSocket。其中,gRPC 使用 Protobuf 进行数据传输, GraphQL 和 RESTful 往往使用 JSON 进行传输。 为了把访问后台 API 的网络传输细节给屏蔽掉,并为上层模块提供统一的访问接口,我们在架构 App 的时候,往往会把网络访问封装成一个独立的 Networking 模块。

底层 HTTP 网络通信模块

该模块把所有 HTTP 请求封装起来,核心是APISession协议。下面是它的定义。

protocol APISession {
   associatedtype ReponseType: Codable
   func post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
}
  1. 只提供了一个POST方法
  2. 统一用ResponseType来做接收对象(当泛型用)
  • 实现协议的时候可以重定义这个ResponseType:
  • typealias ReponseType = Response
  • 但一般接口的最外层可能都是固定的,比如code, message, data,所以它可以不必是个泛型/协议而是一个具体的类型
  • 不过从后面的代码可知,它对应的是data的类型(大部分网络请求的封装也都是这么处理的)

用一个扩展来提供post的默认实现:

extension APISession {
   func post(_ path: String, headers: HTTPHeaders = [:], parameters: Parameters? = nil) -> Observable<ReponseType> {
       return request(path, method: .post, headers: headers, parameters: parameters, encoding: JSONEncoding.default)
   }
}

private func request(_ path: String, method: HTTPMethod, headers: HTTPHeaders, parameters: Parameters?, encoding: ParameterEncoding) -> Observable<ReponseType> {
       let url = baseUrl.appendingPathComponent(path)
       let allHeaders = HTTPHeaders(defaultHeaders.dictionary.merging(headers.dictionary) { $1 })
       return Observable.create { observer -> Disposable in
           let queue = DispatchQueue(label: "moments.app.api", qos: .background, attributes: .concurrent)
           let request = AF.request(url, method: method, parameters: parameters, encoding: encoding, headers: allHeaders, interceptor: nil, requestModifier: nil)
               .validate()
               .responseJSON(queue: queue) { response in
                   // 处理返回的 JSON 数据
               }
           return Disposables.create {
               request.cancel()
           }
       }
   }
  1. 我们首先使用Observable.create()方法来创建一个 Observable 序列并返回给调用者
  2. 然后在create()方法的封包里使用 Alamofire 的request()方法发起网络请求。
  3. 为了不阻挡 UI 的响应,我们把该请求安排到后台队列中执行。
  4. 当我们得到返回的 JSON 以后,会使用下面的代码进行处理。
switch response.result {
case .success:
    guard let data = response.data else {
        // if no error provided by Alamofire return .noData error instead.
        observer.onError(response.error ?? APISessionError.noData)
        return
    }
    do {
        let model = try JSONDecoder().decode(ReponseType.self, from: data)
        observer.onNext(model)
        observer.onCompleted()
    } catch {
        observer.onError(error)
    }
case .failure(let error):
    if let statusCode = response.response?.statusCode {
        observer.onError(APISessionError.networkError(error: error, statusCode: statusCode))
    } else {
        observer.onError(error)
    }
}

很简单,就是判断有没有data键(取决于后端),有的话就转化成泛型指定的类别,广播出去并关闭数据流;没有就报错,广播出去;请求出现了异常或果断转对象那出现了异常也广播出去。

上面的例子把接口加了一层协议,这是将网络层并进行了抽象,方便以后切换请求方式(比如以后想从GraphQL变成RESTful),类似的有以前切换数据库,这属于面向接口(协议)编程的内容,初创团队想这么想会有过度设计的嫌疑(虽然确实能解决项目初始的时候底层技术栈还没确定的问题)。

比如如下,一个接口,一个实现:

protocol GetMomentsByUserIDSessionType {
   func getMoments(userID: String) -> Observable<MomentsDetails>
}

func getMoments(userID: String) -> Observable<MomentsDetails> {
    let session = Session(userID: userID)
    return sessionHandler(session).map { 
        $0.data.getMomentsDetailsByUserID }
}

// sessionHandler是init时候通过闭包送进来的
init(togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared, sessionHandler: @escaping (Session) -> Observable<Response> = {
    $0.post($0.path, headers: $0.headers, parameters: $0.parameters)
}) {
    self.togglesDataStore = togglesDataStore
    self.sessionHandler = sessionHandler
}
  • 其中$0表示入口参数Session的对象,
  • 由于Session遵循了APISession协议,它可以直接调用APISession的扩展方法post来发起 HTTP POST 请求,并获取类型为Response的返回值。

不要被getMomentsDetailsByUserID迷惑了,它不是个方法,只是一个键:

{
 "data": {
   "getMomentsDetailsByUserID": {
     // MomentsDetails object
     "userDetails": {...},
      "moments": [...]
   }
 }
}

相应的数据结构则定义为:

struct Response: Codable {
    let data: Data
    struct Data: Codable {
        let getMomentsDetailsByUserID: MomentsDetails
    }
}
  • 可见抽取的是json里面的特定的键,说明后面还会有处理,不会原样映射。
  • map干的就是把detail从json的data里取出来挂到Response上

接着我们看看Session结构体的具体实现。 该结构体负责准备 GraphQL 请求的数据,这些数据包括 URL 路径、HTTP 头和参数。URL 路径比较简单,是一个值为/graphql的常量。HTTP 头也是一个默认的HTTPHeaders对象。最重要的数据是类型为Parameters的parameters属性。

init(userID: String) {
    let variables: [AnyHashable: Encodable] = ["userID": userID]
    parameters = ["query": Self.query,
                  "variables": variables]
}

可见把参数封了一层,包到了variables里,之所以要包一层,因为还要送一个query。注意,本节说的都是针对特定后端的,比如这里讲的是GraphQL,所有的 GraphQL 的请求都需要发送 Query:

private static let query = """
    query getMomentsDetailsByUserID($userID: ID!) {
      getMomentsDetailsByUserID(userID: $userID) {
        userDetails {
          id
          name
          avatar
          backgroundImage
        }
        moments {
          id
          userDetails {
            name
            avatar
          }
          type
          title
          photos
          createdDate
        }
      }
    }
"""
}
  1. 这不是给swift看的,所以身份只是一个字符串。
  • 可以理解为写SQL,也是字符串
  1. 不要被反复使用的getMomentsDetailsByUserID迷惑了,名字可以变的,不需要内外层保持一致,但需要与调用者保证一致
  2. 这里表示这个接口返出来的json是由getMomentsDetailsByUserID引导的,包含了两个键(userDetailsmoments)与前面的果断对照看一下
  3. query里的变量来自variables变量,所以params需要两个参数
  4. 本质上是把SQL串和查询参数通过HTTP来请求,所以没什么新鲜内容

以上query会产生如下返回:

{
 "userDetails": {
   "id": "0",
   "name": "Jake Lin",
   "avatar": "https://avatar-url",
   "backgroundImage": "https://background-image-url"
 },
 "moments": [
   {
     "id": "0",
     "userDetails": {
       "name": "Taylor Swift",
       "avatar": "https://another-avatar-url"
     },
     "type": "PHOTOS",
     "title": null,
     "photos": [
       "https://photo-url"
     ],
     "createdDate": "1615899003"
   }
 ]
}

接下来要做的,上面定义Response模型的时候做过一次了,那里提取出了MomentsDetails模型,在这里定义。 其实就是对着GraghQL的schema抄一遍,关键字基本都差不多

type MomentsDetails {
 userDetails: UserDetails!
 moments: [Moment!]!
}

type Moment {
 id: ID!
 userDetails: UserDetails!
 type: MomentType!
 title: String
 url: String
 photos: [String!]!
 createdDate: String!
}

type UserDetails {
  id: ID!
  name: String!
  avatar: String!
  backgroundImage: String!
}

enum MomentType {
 URL
 PHOTOS
}

// 有了上面的 GraphQL Schema,加上 JSON 数据结构,我们可以完成MomentsDetails的映射。
 struct MomentsDetails: Codable {
   let userDetails: UserDetails
   let moments: [Moment]
}
  • 具体做法是把 GraphQL 中的type映射成struct,然后每个属性都使用let来定义成常量。
  • 在 GraphQL 中,!符合表示非空类型,而 Swift 中恰好相反,标记的是s可空(?符号)。
struct UserDetails: Codable {
    let id: String
    let name: String
    let avatar: String
    let backgroundImage: String
}

// 接着我们看看Moment类型定义。
struct Moment: Codable {
    let id: String
    let userDetails: MomentUserDetails
    let type: MomentType
    let title: String?
    let url: String?
    let photos: [String]
    let createdDate: String
    struct MomentUserDetails: Codable {
        let name: String
        let avatar: String
    }

  // 注意这里,枚举映射为枚举,这里是内联的
  // GraphQL 会通过字符串来传输enum
  enum MomentType: String, Codable {
      case url = "URL"
      case photos = "PHOTOS"
  }
}

Codable是两个协议(编码和解码)的简写:

public typealias Codable = Decodable & Encodable
JSON数据JSONDecoderSwift ModelSwift ModelJSONEncoderJSON数据\begin{array}{l} \small\text{JSON数据} \to \small\text{JSONDecoder} \to \small\text{Swift Model} \\ \small\text{Swift Model} \to \small\text{JSONEncoder} \to \small\text{JSON数据} \end{array}
  • 在 Swift 4 之前,我们需要使用JSONSerialization来反序列化 JSON 数据,然后把每一个属性单独转换成所需的类型。
  • 后来出现 SwiftyJSON 等库,帮我们减轻了一部分 JSON 转型工作,但还是需要大量手工编码来完成映射。
  • 1Swift 4 以后,出现了Codable协议,我们只需要把所定义的 Model 类型遵守该协议,Swift 在调用JSONDecoderdecode方法时就能自动完成转型。这样既能减少编写代码的数量,还能获得原生的性能。
let model = try JSONDecoder().decode(ReponseType.self, from: data)
  • 加上try语句才会让decode方法返nil

数据层架构:仓库模式

Repository 模式

所谓 Repository 模式,就是为数据访问提供抽象的接口,数据使用者在读写数据时,只调用相关的接口函数,并不关心数据到底存放在网络还是本地,也不用关心本地数据库的具体实现。使用 Repository 模式有以下几大优势:

  • Repository 模块作为唯一数据源统一管理所有数据,能有效保证整个 App 数据的一致性;
  • Repository 模块封装了所有数据访问的细节,可提高程序的可扩展性和灵活性,例如,在不改变接口的情况下,把本地存储替换成其他的数据库;
  • 结合 RxSwift 的 Subject, Repository 模块能自动更新 App 的数据与状态。

  • ViewModel 模块是 Repository 模块的上层数据使用者,在朋友圈功能里面,MomentsTimelineViewModel和MomentListItemViewModel都通过MomentsRepoType的momentsDetailsSubject 来订阅数据的更新。
  • Repository 模块分成两大部分: RepoDataStore
    • 其中 Repo 负责统一管理数据(如访问网络的数据、读写本地数据),并通过 Subject 来为订阅者分发新的数据。
      • Repo 由MomentsRepoType协议和遵循该协议的MomentsRepo结构体所组成。MomentsRepoType协议用于定义接口,而MomentsRepo封装具体的实现,
      • 当MomentsRepo需要读取和更新 BFF 的数据时,会调用 Networking 模块的组件。而当MomentsRepo需要读取和更新本地数据时,会使用到 DataStore。
    • DataStore 负责本地数据的存储,它由PersistentDataStoreType协议和UserDefaultsPersistentDataStore结构体所组成。其中,PersistentDataStoreType协议用于定义本地数据读写的接口。而UserDefaultsPersistentDataStore结构体是其中一种实现。
    • 从名字可以看到,该实现使用了 iOS 系统所提供的 UserDefaults 来存储数据。
    • 假如我们需要支持 Core Data,那么可以提供另外一个结构体来遵循PersistentDataStoreType协议,比如把该结构体命名为CoreDataPersistentDataStore,并使用它来封装所有 Core Data 的访问细节。有了 DataStore 的接口,我们可以很方便地替换不同的本地数据库。

Repository模式实现

先看一下 DataStore 的接口,一个存,一个取:

protocol PersistentDataStoreType {
    var momentsDetails: ReplaySubject<MomentsDetails> { get }
    func save(momentsDetails: MomentsDetails)
}

UserDefaults版本的实现:

struct UserDefaultsPersistentDataStore: PersistentDataStoreType {
    static let shared: UserDefaultsPersistentDataStore = .init() // 这样就单例了?
    private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
    private let disposeBage: DisposeBag = .init()
    private let defaults = UserDefaults.standard
    private let momentsDetailsKey = String(describing: MomentsDetails.self)
    private init() {
        defaults.rx
            .observe(Data.self, momentsDetailsKey)
            .compactMap { $0 }
            .compactMap { try? JSONDecoder().decode(MomentsDetails.self, from: $0) }
            .subscribe(momentsDetails) 
            // momentsDetails是一个ReplaySubject,所以它也是个中转,等待别人的订阅
            // 它又是个属性,所以别人可以通过这个datastore找到它(来订阅)
            .disposed(by: disposeBage)
    }
    func save(momentsDetails: MomentsDetails) {
        if let encodedData = try? JSONEncoder().encode(momentsDetails) {
            defaults.set(encodedData, forKey: momentsDetailsKey)
        }
    }
}
  • 因为UserDefaultsPersistentDataStore遵循了PersistentDataStoreType协议,因此需要实现momentsDetails属性和save()方法。

  • 其中momentsDetails属性为 RxSwfitReplaySubject类型。它负责把数据的更新事件发送给订阅者。

  • 在init()方法中,我们通过了 Key 来订阅 UserDefaults 里的数据更新,

    • 一旦与该 Key 相关联的数据发生了变化,我们就使用JSONDecoder来把更新的数据解码成MomentsDetails类型,
    • 然后发送给momentsDetailsSubject 属性。
    • 这样momentsDetails属性就可以把数据事件中转给外部的订阅者了。

    MomentsRepoType

  protocol MomentsRepoType {
    var momentsDetails: ReplaySubject<MomentsDetails> { get }
    func getMoments(userID: String) -> Observable<Void>
    func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void>
}

用一个MomentsRepo来实现它,可以想见,这个momentsDetails必然来自于上面的DataStoremomentsDetails属性:

init(persistentDataStore: PersistentDataStoreType,
             getMomentsByUserIDSession: GetMomentsByUserIDSessionType,
             updateMomentLikeSession: UpdateMomentLikeSessionType) {
    self.persistentDataStore = persistentDataStore
    self.getMomentsByUserIDSession = getMomentsByUserIDSession
    self.updateMomentLikeSession = updateMomentLikeSession
    persistentDataStore
        .momentsDetails // 在这里被订阅
        .subscribe(momentsDetails) // 继续分发
        .disposed(by: disposeBag)
}
// 业务逻辑的实现很明确,监听网络回调,存到datastore里
// 上面的代码里,已经把datastore里监听的特定数据通过repo广播出去了
func getMoments(userID: String) -> Observable<Void> {
    return getMomentsByUserIDSession
        .getMoments(userID: userID)
        .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
        .map { _ in () }
        .catchErrorJustReturn(())
}
func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void> {
    return updateMomentLikeSession
        .updateLike(isLiked, momentID: momentID, fromUserID: userID)
        .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
        .map { _ in () }
        .catchErrorJustReturn(())
}

应用:

momentsRepo.momentsDetails.subscribe(onNext: {
    // 接收并处理朋友圈数据更新
}).disposed(by: disposeBag)

RxSwift Subject

可以看到,在 Repository 模块里面,大量使用了 RxSwiftSubject 来中转数据事件。 回顾一下,在 RxSwift 里面,常见的 SubjectPublishSubjectBehaviorSubjectReplaySubject

PublishSubject

PublishSuject 用于发布(Publish)事件,它的特点是订阅者只能接收订阅后的事件:

let publishSubject = PublishSubject<Int>()
publishSubject.onNext(1)
let observer1 = publishSubject.subscribe { event in
    print("observer1: \(event)")
}
observer1.disposed(by: disposeBag)
publishSubject.onNext(2)
let observer2 = publishSubject.subscribe { event in
    print("observer2: \(event)")
}
observer2.disposed(by: disposeBag)
publishSubject.onNext(3)
publishSubject.onCompleted()
publishSubject.onNext(4)

输出:

observer1: next(2)
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed
  • 首先,next(1)谁都收不到,因为它发布的时候没人订阅
  • 其次,next(4)谁也收不到,因为发布者发布了completed通知
  • 剩下的就是看订阅时机了

顺便回顾下disposeBag,相比订阅者主动dispose,他们并不知道什么时机可以去执行,所以有了disposeBag,由发布者通知订阅者可以dispose

这只是一个形象的说法,事实上如果是这样的话,发送complete通知已经能达到效果了,总之由bag来管理就可以不需要手动dispose

这个订阅时机之所以很重要,是因为如果你把数据变成订阅机制,那么你在产生数据的时候如果订阅都还没实例化好,那么就接收不到这些数据了(它不像是回调和promise)。

BehaviorSubject

BehaviorSubject 用于缓存一个事件,当订阅者订阅 BehaviorSubject 时,会马上收到该 Subject 里面最后一个事件。

把上一节的代码改成 BehaviorSubject

let behaviorSubject = BehaviorSubject<Int>(value: 1) // 注意这里,有个初始值

输出会变为:

observer1: next(1) // 订阅者1订阅前的一条消息
observer1: next(2)
observer2: next(2) // 订阅者2订阅前的一条消息
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed

ReplaySubject

BehaviorSubject 只能缓存一个事件,当我们需要缓存 N 个事件时,就可以使用 ReplaySubject(所以这个方法显然跟了一个参数N)。

let replaySubject = ReplaySubject<Int>.create(bufferSize: 2)
  • 我们的demo里最多就错过两个消息,所以你设为2的话,已经可以打印出所有事件了。
  • 所以如果是网络请求类的,用一个BehaviorSubject一般是不会漏掉请求结果的,因为一个请求自然只有一次响应

回顾下上面的repository写法里:

private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
  • 可见,即使用了 ReplaySubject,我们也只缓存了一个事件,当 BehaviorSubject 用了。但是
  • BehaviorSubject 需要一个初始值来初始化(占位值?),如果我没办法提供,只能把存放的类型定义为 Optional (可空)类型,
  • 使用 ReplaySubject却不需要(为什么replay下就不需要用optional了?)
  • 这就是实例代码里即使只需要缓存一个事件,也选择使用 ReplaySubject的原因

除了上面的三个 Subject 以外,RxSwift 还为我们提供了两个特殊的 Subject:PublishRelayBehaviorRelay,它们的名字和 BehaviorSubjectReplaySubject 非常类似,区别是 Relay 只中继next事件,我们并不能往 Relay 里发送completederror事件。

下面是一些在项目场景中使用 Subject 的经验,希望对你有帮助。

  1. 如果需要把 Subject 传递给其他类型发送消息,例如在朋友圈时间轴列表页面把 Subject 传递给各个朋友圈子组件,然后接收来自子组件的事件。 这种情况我们一般会传递 PublishSubject,因为在传递前在主页面(如例子中的朋友圈时间轴页面)已经订阅了该 PublishSubject,子组件所发送事件,主页面都能收到。
  2. BehaviorSubject 可用于状态管理,例如管理页面的加载状态,开始时可以把 BehaviorSubject 初始化为加载状态,一旦得到返回数据就可以转换为成功状态。
  3. 因为 BehaviorSubject 必须赋予初始值,但有些情况下,我们并没有初始化,如果使用 BehaviorSubject 必须把其存放的类型定义为 Optional 类型。为了避免使用 Optional,我们可以使用 bufferSize 为 1 的 ReplaySubject 来代替 BehaviorSubject。
  4. Subject 和 Relay 都能用于中转事件,当中转的事件中没有completed或error时,我们都选择 Relay。

View Model 架构:如何准备 UI 层的数据?

  • 为了让 UI 能正确显示,我们需要把 Model 数据进行转换。
    • 例如,当我们显示图片的时候,需要把字符串类型的 URL 转换成 iOS 所支持 URL 类型;当显示时间信息时,需要把 UTC 时间值转换成设备所在的时区。
  • 如果我们把所有类型转换的逻辑都放在 UI/View 层里面,作为 View 层的 View Controller 往往会变得越来越臃肿。
  • 我们使用 MVVM 模式来转化,这里使用了RxSwift的Operator(操作符)

ViewModel 模式的实现

首先看一下ListViewModel协议的定义。

  • 一个网络请求的方法
  • 网络请求结果(列表),用一个behaviorSubject序列来广播出去
  • 监听list映射了一个是否为空的判断出来
  • 一个是否出错的序列
  • 一个网络请求的方法
protocol ListViewModel {
    var hasContent: Observable<Bool> { get }
    var hasError: BehaviorSubject<Bool> { get }
    func trackScreenviews()
    func loadItems() -> Observable<Void>
    var listItems: BehaviorSubject<[SectionModel<String, ListItemViewModel>]> { get }
}

注意, 这里把loadItemslistItems分开了, 盲猜下, 一个是行为, 一个是组织数据, 具体得到代码里找一下使用方法. 如果不这么做, 我们可能直接observe loadItems()的结果作为列表数据

extension ListViewModel {
    var hasContent: Observable<Bool> {
        return listItems
            .map(\.isEmpty) // 映射为了bool
            .distinctUntilChanged() // 只有值发生改变时才发送新事件
            .asObservable()
    }
}
  • 上面提供了一个hasContent的默认实现
  • 这个方法使用mapdistinctUntilChanged操作符来把listItems转换成 Bool 类型的hasContent
    • 也就是说它其实就是转化的另一个序列(而这个序列是behaviorSubject,即数据来源另一个序列,还会把变化广播出去)
    • 所以hasContent应该也成了一个behaviorSubject

下面是列表的内容(即listViewModel的内容)

protocol ListItemViewModel {
    static var reuseIdentifier: String { get }
}
extension ListItemViewModel {
    static var reuseIdentifier: String {
        String(describing: self)
    }
}

这里连UITableViewCell所需要的idendifier也接管了,真正做了了前端只管用(即期望是一个listItem对应一个cell)

上述就是ListViewModel协议的定义,接下来看它的实现结构体MomentsTimelineViewModel

由于MomentsTimelineViewModel遵循了ListViewModel协议,因此需要实现了该协议中listItemshasError属性以及loadItems()trackScreenviews()方法。

我们首先看一下loadItems()方法的实现。

func loadItems() -> Observable<Void> {
    return momentsRepo.getMoments(userID: userID)
}

当 ViewModel 需要读取数据的时候,会调用 Repository 模块的组件,在朋友圈功能中,我们调用了MomentsRepoType的getMoments()方法来读取数据。 接着看看trackScreenviews()方法的实现。在该方法里面,我们调用了TrackingRepoType的trackScreenviews()方法来发送用户的行为数据,具体实现如下。

func trackScreenviews() {
    trackingRepo.trackScreenviews(ScreenviewsTrackingEvent(screenName: L10n.Tracking.momentsScreen, screenClass: String(describing: self)))
 }

ViewModel 模块的一个核心功能,是把 Model 数据转换为用于 UI 呈现所需的 ViewModel 数据,我通过下面代码看它是怎样转换的。

func setupBindings() {
 momentsRepo.momentsDetails
     .map {
         [UserProfileListItemViewModel(userDetails: $0.userDetails)]
             + $0.moments.map { MomentListItemViewModel(moment: $0) }
     }
     .subscribe(onNext: {
         listItems.onNext([SectionModel(model: "", items: $0)])
     }, onError: { _ in
         hasError.onNext(true)
     })
     .disposed(by: disposeBag)
}

从代码中你可以发现,我们订阅了momentsRepo的momentsDetails属性,接收来自 Model 的数据更新。因为该属性的类型是MomentsDetails,而 View 层用所需的数据类型为ListItemViewModel。我们通过 map 操作符来进行类型转换,在转换成功后,调用listItems的onNext()方法把准备好的 ViewModel 数据发送给 UI。如果发生错误,就通过hasError属性发送出错信息。

  • 在 map 操作符的转换过程中,我们分别使用了UserProfileListItemViewModelMomentListItemViewModel结构体来转换用户简介信息和朋友圈条目信息。
  • 这两个结构体都遵循了ListItemViewModel协议。
  • map的实现很诡异,直接一个+号,原来是因为它们实现的是同一个协议,等同于同类型,干脆就把用户信息变成信息流里的第一条数据(也就是说取的时候需要自行区分)

接下来是它们的实现,首先看一下UserProfileListItemViewModel。

struct UserProfileListItemViewModel: ListItemViewModel {
    let name: String
    let avatarURL: URL?
    let backgroundImageURL: URL?
    init(userDetails: MomentsDetails.UserDetails) {
        name = userDetails.name
        avatarURL = URL(string: userDetails.avatar)
        backgroundImageURL = URL(string: userDetails.backgroundImage)
    }
}

MomentListItemViewModel:

init(moment: MomentsDetails.Moment, now: Date = Date(), relativeDateTimeFormatter: RelativeDateTimeFormatterType = RelativeDateTimeFormatter()) {
    userAvatarURL = URL(string: moment.userDetails.avatar)
    userName = moment.userDetails.name
    title = moment.title
    if let firstPhoto = moment.photos.first {
      photoURL = URL(string: firstPhoto)
    } else {
      photoURL = nil
    }
    // 以下不过实现是展示"5分钟前"之类的需求
    var formatter = relativeDateTimeFormatter
    formatter.unitsStyle = .full
    if let timeInterval = TimeInterval(moment.createdDate) {
      let createdDate = Date(timeIntervalSince1970: timeInterval)
      postDateDescription = formatter.localiaedString(for: createdDate, relativeTo: now)
    } else {
      postDateDescription = nil
    }

关于RxSwift的操作符,可跳转这个链接transfer, filter etc.

UI层架构

在 MVVM 模式里,View 依赖于 ViewModel。作为 View 的BaseTableViewController依赖于 ViewModel 层的ListViewModel协议,这使得BaseTableViewController只依赖于接口而不是具体的类型,从而提高了程序的可扩展性。同时,BaseTableViewController还定义了三个属性来显示 UI 控件:

  • tableView属性用于显示一个 TableView;
  • activityIndicatorView属性用于显示俗称小菊花的加载器;
  • errorLabel用于显示出错信息的标签控件。

一个定义示例:

private let tableView: UITableView = configure(.init()) {
    $0.translatesAutoresizingMaskIntoConstraints = false
    $0.separatorStyle = .none
    $0.rowHeight = UITableView.automaticDimension
    $0.estimatedRowHeight = 100
    $0.contentInsetAdjustmentBehavior = .never
    $0.backgroundColor = UIColor.designKit.background
}

其中configure()方法是封装的一个通用方法, 这样可以使得定义和配置一个控件的代码统一起来, 不至于出现这里定义那里配置的随意性。其实就是简单地送个闭包进去:

func configure<T: AnyObject>(_ object: T, closure: (T) -> Void) -> T {
    closure(object)
    return object
}

数据绑定

Moments App 使用了 RxSwift 把 ViewModel 层和 View 层进行绑定,绑定的代码在setupBindings()函数里,具体如下。

func setupBindings() {
    tableView.refreshControl = configure(UIRefreshControl()) {
        let refreshControl = $0
        $0.rx.controlEvent(.valueChanged)
            .filter { refreshControl.isRefreshing }
            .bind { [weak self] _ in self?.loadItems() }
            .disposed(by: disposeBag)
    }
    let dataSource = RxTableViewSectionedReloadDataSource<SectionModel<String, ListItemViewModel>>(configureCell: { _, tableView, indexPath, item in
        let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: type(of: item)), for: indexPath)
        (cell as? ListItemCell)?.update(with: item)
        return cell
    })
    viewModel.listItems
        .bind(to: tableView.rx.items(dataSource: dataSource))
        .disposed(by: disposeBag)
    viewModel.hasError
        .map { !$0 }
        .bind(to: errorLabel.rx.isHidden)
        .disposed(by: disposeBag)
}

这个函数由三部分组成,

  • 第一部分是通过 RxSwiftRxCocoa ,把UIRefreshControl控件里的isRefreshing事件和loadItems()函数绑定起来。当用户下拉刷新控件的时候会调用loadItems()函数来刷新列表的数据。
  • 第二部分是把 TableView Cell 控件与 ViewModel 的listItemsSubject 属性绑定起来,当listItems发出新的事件时,我们会调用ListItemCell的update(with viewModel: ListItemViewModel)方法来更新 UI。经过了这一绑定,UI 就能随着 ViewModel 的数据变化而自动更新。
    • 这里需要注意的是要多读一下RxCocoa的api, 了解下列表的绑定, 比如这个例子里, listItems发了消息让table接, 但是table是用一个datasource来处理cell的创建和更新的, 因此, 而这个datasource则是纯RxCocoa自定义的东西, 也是它在真正处理listitems发过来的数据, 也就是说它是个必要的中转/适配器
  • 第三部分与第二部分类似,都是把 ViewModel 与 View 层的控件进行绑定。在这里,我们把 ViewModel 的hasErrorSubject 属性绑定到errorLabel.rx.isHidden属性来控制errorLabel是否可见。

数据绑定以后,我们一起看看loadItems()函数的实现。

func loadItems() {
    viewModel.hasError.onNext(false)
    viewModel.loadItems()
        .observeOn(MainScheduler.instance)
        .do(onDispose: { [weak self] in
            self?.activityIndicatorView.rx.isAnimating.onNext(false)
            self?.tableView.refreshControl?.endRefreshing()
        })
        .map { false }
        .startWith(true)
        .distinctUntilChanged()
        .bind(to: activityIndicatorView.rx.isAnimating)
        .disposed(by: disposeBag)
}

loadItems()本应是加载数据, 并且渲染UI, 但这段代码我们只看到了把它跟各种状态控件绑定了起来, 比如hasError, activityIndicatorView, refresControl等, 只能认为, refreshControl能触发tableView的重加载工作, 这里最好去翻翻代码 --> 好像是loadItems是存数据库了, 而不是接口透传

假如用户在调用 ViewModel 的loadItems()方法的过程中,退出列表页面,我们通过.do(onDispose:{})方法来停止activityIndicatorViewrefreshControl两个控件的刷新动画。

从代码中你可以看到,尽管我们想更新 UI 层的errorLabel控件,却没有直接通过errorLabel.isHidden = true的方式来更新,而是通过 ViewModel 的hasError属性来完成。这是因为我要保证 View/UI 层都是由 ViewModel 驱动,通过单方向的数据流来减少 Bug ,从而提高代码的可维护性。

这套UI设计了一个头部cell和一个可重复的cell两部分, 因此做了两个基础类, 并且按一理的风格, 为每个类都做了一个protocol, 显然只有一个方法: update(with viewModel: T)

  • LiteItemCell protocol 和其实现: BaseTableViewCell
  • ListItemView protocol 和其实现: BaseListItemView 比如绕的是, 这个BaseTableViewCell其实还是一个BaseListItemView, 总之要记得这是一套过度设计的系统, 实际中肯定可以简化很多层. 作为教学, 可能也复杂了些, 把需要讲解的点和一些实际应用给混在一起了. 我们看看这两块的代码:
// cell 部分
protocol ListItemCell: class {
    func update(with viewModel: ListItemViewModel)
}

final class BaseTableViewCell<V: BaseListItemView>: UITableViewCell, ListItemCell {
    private let view: V
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        view = .init()
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        selectionStyle = .none
        contentView.addSubview(view)
        view.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
    required init?(coder: NSCoder) {
        fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)
    }
    func update(with viewModel: ListItemViewModel) {
        view.update(with: viewModel)
    }
}

// view部分
protocol ListItemView: class {
    func update(with viewModel: ListItemViewModel)
}
class BaseListItemView: UIView, ListItemView {
    lazy var disposeBag: DisposeBag = .init()
    func update(with viewModel: ListItemViewModel) {
        fatalError(L10n.Development.fatalErrorSubclassToImplement)
    }
}

讲解:

  1. 本质上, 这两个视图都是一个BaseListItemView, 下面称基类 注意, 它是一个普通的UIView, 而不是UITableViewCell
  2. Cell直接把基类addSubView
  3. 因为做了两个类, 所以update方法不得不复制了两次, 其实是一模一样的, 这是败笔
  4. 基类里, 把update方法默认fatal了, 子类里, 说的是使用传入的view的update方法, 所以虽然没有明确fatal, 事实上也是需要自行实现的.

能动态增加功能的架构

规范的架构与框架不仅具有良好的可扩展性,例如,可以灵活地替换网络层、数据库甚至 UI 层的实现,而且还为开发者提供了统一的开发步骤与规范,方便新功能的快速迭代。以下以增加一个点赞功能为例, 可以按照以下五个步骤:

  • 增加“添加点赞功能”的功能开关;
  • 开发网络层来更新 BFF 的点赞信息;
  • 开发 Repository 层来存储数据;
  • 开发 ViewModel 层来准备 UI 所需的数据;
  • 开发 UI/View 层呈现点赞按钮和点赞朋友列表。

增加功能开关

当我们开发一个周期比较长的新功能时,通常会使用功能开关。 如果没有功能开关,当开发周期超过一周以上时,我们就不得不把开发中的功能放在一个“长命”功能分支下,直到整个功能完成后才合并到主分支,这往往会增加合并分支的难度。 另一种方法是延迟发布的时间,在功能完整开发出来后才进行发布。假如有多个团队一直在开发新功能,那么发布计划就可能一直在延迟

其它步骤不赘述, 有一个地方需要注意下, 教程用的是GraphQL, 所有的查询是客户端构建的, 所以是可以在全链路里点赞属性还没开发好的情况下修改需要返回的字段, 而不是像一般的开发, 先把预期要返回的字段建模进去, 等到部署好了自然这个字段谅有值了:

private static let query = """
   query getMomentsDetailsByUserID($userID: ID!, $withLikes: Boolean!) {
     getMomentsDetailsByUserID(userID: $userID) {
         // other fields
         createdDate
         isLiked @include(if: $withLikes)
         likes @include(if: $withLikes) {
           id
           avatar
         }
       }
     }
   }
"""

注意上面的@include, 这是语言特征, 就不说了

TDD与单元测试

TDD(Test-Driven Development) 的核心是编写单元测试。

  • 单元测试能方便我们模拟不同的测试场景,覆盖不同的边界条件,从而提高代码的质量并减少 Bug 的数量。
  • 同时,使用 TDD 所开发的代码能降低模块间的耦合度,提高模块的灵活性和可扩展性。

在编写测试代码时候,我们一般遵守 AAA 步骤,所谓AAA 就是 Arrange、Act 和 Assert。

  • Arrange:用于搭建测试案例,例如,初始化测试对象及其依赖。
  • Act:表示执行测试,例如,调用测试对象的方法。
  • Assert:用于检验测试的结果。

基本上都是检查:

  • 必要的步骤(方法)被触发
  • 关联步骤被触发
  • 正确地解析了返回值
  • 正确地处理了错误, 空值等

剩下的就是你选的测试框架是怎么处理模拟数据, 初始化等等的, 最后, 就是依赖注入的架构, 可以使得测试用例能够传入模拟数据源, 而如果你是在实现体内写死的比如网络请求, 数据库连接, 单元测试就不能做了, 一做就触发了真正的业务逻辑了. 本教程使用了QuickNimble

一个viewmodel的测试用例:

final class MomentsTimelineViewModelTests: QuickSpec {
    override func spec() {
        describe("MomentsTimelineViewModel") {
            var testSubject: MomentsTimelineViewModel!
            beforeEach {
                testSubject = MomentsTimelineViewModel() // Arrange
            }
            context("loadItems()") {
                beforeEach {
                    testSubject.loadItems() // Act
                }
                it("call `momentsRepo.getMoments` with the correct parameters") {
                    expect(mockMomentsRepo.getMomentsHasBeenCalled).to(beTrue()) // Assert
                }
                it("check another assertion") { }
            }
            context("anotherMethod()") { }
        }
    }
}
  • 测试类型中的每一个公共的方法和属性都要测试, 上例用的是context()方法
  • spce和context里都有各自的beforeEach, 能看出这是初始化测试数据的入口
  • 然后就是itexpect的组合

网络层

因为使用了RxSwift, 可以引用RxTesst库来简化测试流程. 首先,我们在describe("GetMomentsByUserIDSession")函数里定义需要初始化的变量,代码如下:

var testSubject: GetMomentsByUserIDSession!
var testScheduler: TestScheduler!
var testObserver: TestableObserver<MomentsDetails>!
var mockResponseEvent: Recorded<Event<GetMomentsByUserIDSession.Response>>!
  • testSubject是测试的对象,在这个例子中是我们需要测试的GetMomentsByUserIDSession。
  • testScheduler的类型是来自 RxTest 的TestScheduler,是一个用于测试的排程器。
  • testObserver的类型是 RxTest 的TestableObserver,用来订阅 Observable 序列里的事件,并通过接收到的事件来检查测试的结果。
  • mockResponseEvent是Recorded类型,也是来自 RxTest,用于模拟事件的发送,例如模拟成功接收到网络数据事件或者错误事件。

所需的变量定义完毕以后,可以在beforeEach()方法里面初始化testScheduler和testObserver,具体代码如下:

beforeEach {
    testScheduler = TestScheduler(initialClock: 0)
    testObserver = testScheduler.createObserver(MomentsDetails.self)
}

初始化完成, 看一个用例:

context("getMoments(userID:)") {
    context("when response status code 200 with valid response") {
        beforeEach {
            mockResponseEvent = .next(100, TestData.successResponse)
            getMoments(mockEvent: mockResponseEvent)
        }
    }
}

有两个问题:

  1. 调用getMoments方法, 并不是传一个mock的入参, 是怎么回事?
  2. 里面.next出了一个successResponse哪来的? 其实是私有方法和私有属性, 所以单元测试不是那么简单的事, 需要一大堆的准备工作
func getMoments(mockEvent: Recorded<Event<GetMomentsByUserIDSession.Response>>) {
    let testableObservable = testScheduler.createHotObservable([mockEvent])
    testSubject = GetMomentsByUserIDSession { _ in testableObservable.asObservable() }
    testSubject.getMoments(userID: "0").subscribe(testObserver).disposed(by: disposeBag)
    testScheduler.start()
}

private struct TestData {
    static let successResponse: GetMomentsByUserIDSession.Response = {
        let response = try! JSONDecoder().decode(GetMomentsByUserIDSession.Response.self,
                                               from: TestData.successjson.data(using: .utf8)!)
        return response
    }()
    static let successjson = """
    {
      "data": { ... } // JSON 数据, 可以从BFF的正确返回里拷贝一段
    }
    """
}

为了mock rx特性的网络请求, 上面初始化的test开头的几个属性就起作用了, 上面都是数据准备, 下面是断言部分:

it("should complete and map the response correctly") {
    let expectedMomentsDetails = TestFixture.momentsDetails
    let actualMomentsDetails = testObserver.events.first!.value.element!
    expect(actualMomentsDetails).toEventually(equal(expectedMomentsDetails))
}

失败的用例:

context("when response status code non-200") {
    let networkError: APISessionError = .networkError(error: MockError(), statusCode: 500)
    beforeEach {
        mockResponseEvent = .error(100, networkError, GetMomentsByUserIDSession.Response.self)
        getMoments(mockEvent: mockResponseEvent)
    }
    it("should throw a network error") {
        let actualError = testObserver.events.first!.value.error as! APISessionError
        expect(actualError).toEventually(equal(networkError))
    }
}

Repository层/数据层

单元测试只测代码覆盖率, 即期望的代码有没有执行到, 数据层这种来源即可能是硬盘, 也可能是数据库, 也可能是网络请求的, 在架构上就要实现能依赖注入(DI), 这样真实代码和测试代码的依赖关系就解耦了, 测试代码就可以用mock来代替真实的依赖了.

private class MockUserDefaultsPersistentDataStore: PersistentDataStoreType {
    private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
    private(set) var savedMomentsDetails: MomentsDetails?
    func save(momentsDetails: MomentsDetails) {
        savedMomentsDetails = momentsDetails
    }
}
private class MockGetMomentsByUserIDSession: GetMomentsByUserIDSessionType {
    private(set) var getMomentsHasbeenCalled = false
    private(set) var passedUserID: String = ""
    func getMoments(userID: String) -> Observable<MomentsDetails> {
        passedUserID = userID
        getMomentsHasbeenCalled = true
        return Observable.just(TestFixture.momentsDetails)
    }
}

看代码, 其实就是自己手写了一个repository, 只不过是个简单版的, 让代码能跑过去的. 上面代码一个是mock了一个数据库请求, 或者说文件请求(userdefaults本质上是文件), 一个是mock了一个网络请求.

有了这些 Mock 类型以后,我们就可以把它们注入测试对象testSubject中:

beforeEach {
    mockUserDefaultsPersistentDataStore = MockUserDefaultsPersistentDataStore()
    mockGetMomentsByUserIDSession = MockGetMomentsByUserIDSession()
    testSubject = MomentsRepo(persistentDataStore: mockUserDefaultsPersistentDataStore, getMomentsByUserIDSession: mockGetMomentsByUserIDSession)
}

上一节网络层的测试, 用testScheduler.createHotObservable()(即rxTest)创建了一个testableObservable, 本节演示自己写一个observable, 我目前不知道需要两种写法的必要性, 在我看来, 从网络/硬盘/数据库读取东西都是一回事, 还是先看实现吧:

class TestObserver<ElementType>: ObserverType {
    private var lastEvent: Event<ElementType>?
    var lastElement: ElementType? {
        return lastEvent?.element
    }
    var lastError: Error? {
        return lastEvent?.error
    }
    var isCompleted: Bool {
        return lastEvent?.isCompleted ?? false
    }
    func on(_ event: Event<ElementType>) {
        lastEvent = event
    }
}

取详情:

context("momentsDetails") {
    var testObserver: TestObserver<MomentsDetails>!
    beforeEach {
        testObserver = TestObserver<MomentsDetails>() // Arrange
        testSubject.momentsDetails.subscribe(testObserver).disposed(by: disposeBag) // Act
    }
}

// 此时期望是取不到详情的:
it("should be `nil` by default") {
    expect(testObserver.lastElement).to(beNil()) // Assert
}

// 直到onNext了一个数据出来
context("when persistentDataStore has new data") {
    beforeEach {
        mockUserDefaultsPersistentDataStore.momentsDetails.onNext(TestFixture.momentsDetails)
    }
    it("should notify a next event with the new data") {
        expect(testObserver.lastElement).toEventually(equal(TestFixture.momentsDetails)) // Assert
    }
}

取列表:

context("getMoments(userID:)") {
    beforeEach {
        testSubject.getMoments(userID: "1").subscribe().disposed(by: disposeBag)
    }
    it("should call `GetMomentsByUserIDSessionType.getMoments`") {
        expect(mockGetMomentsByUserIDSession.getMomentsHasbeenCalled).to(beTrue())
        expect(mockGetMomentsByUserIDSession.passedUserID).to(be("1"))
    }
    it("should save a `MomentsDetails` object") {
        expect(mockUserDefaultsPersistentDataStore.savedMomentsDetails).to(equal(TestFixture.momentsDetails))
    }
}

ViewModel层

这一层主要把下游数据转化成UI需要的数据,

context("init(userDetails:)") {
    context("when all data provided") {
        beforeEach {
            testSubject = UserProfileListItemViewModel(userDetails: TestFixture.userDetails)
        }
        it("should initialize the properties correctly") {
            expect(testSubject.name).to(equal("Jake Lin"))
            expect(testSubject.avatarURL).to(equal(URL(string: "https://avatars-url.com")))
            expect(testSubject.backgroundImageURL).to(equal(URL(string: "https://background-image-url.com")))
        }
    }
    context("when `userDetails.avatar` is not a valid URL") {
    beforeEach {
        testSubject = UserProfileListItemViewModel(userDetails: MomentsDetails.UserDetails(id: "1", name: "name", avatar: "this is not a valid URL", backgroundImage: "https://background-image-url.com"))
    }
    it("`avatarURL` should be nil") {
        expect(testSubject.avatarURL).to(beNil())
    }
}
}

因为所有的转换逻辑都封装在UserProfileListItemViewModel的init(userDetails:)方法里面,所以我们可以通过测试该init()方法来验证数据转换的逻辑。

统一管理 Certificate 和 Profile

一种思路,用 GitHub 来存储

  1. 建 GitHub 私有 Repo
  2. 生成 GitHubA Access Token
  3. 生成 App Store Connect API Key
  • Users and Access -> Keys -> App Store Connect API

这些api key是连接github和itunes connect必要的凭据, 你可以选择把它们配置在连接的语句里, 或是配置文件里, 或是环境变量里.

配置环境变量的话:

# github的token要base64一下
echo -n your_github_username:your_personal_access_token | base64
export MATCH_GIT_BASIC_AUTHORIZATION=<YOUR BASE64 KEY>
export APP_STORE_CONNECT_API_CONTENT=<App Store Connect API>

配置文件的话, 先创建一个local.keys:


APP_STORE_CONNECT_API_CONTENT=<App Store Connect API for an App Manager>
GITHUB_API_TOKEN=<GitHub API token for accessing the private repo for certificates and provisioning profiles>
MATCH_PASSWORD=<Password for certificates for App signing on GitHub private repo>

再写个脚本来读这些Keys, 自动写到环境变量里(当然你也可以选择在代码里读这个配置文件)

export $(grep -v '^#' ./local.keys | sed 's/#.*//')
export MATCH_GIT_BASIC_AUTHORIZATION=$(echo -n momentsci:$GITHUB_API_TOKEN | base64)

接着在根目录执行以下的命令:

$> source ./scripts/export_env.sh

如果想要开机自动执行这个脚本, 可以在~/.bash_profile里添加:

source ~/projects/path/scripts/export_env.sh

鉴权资料拿到后, 就可以用 fastlane match 自动生成和管理证书和 Provisioning Profile 了.

local.keys这个文件不要传到项目文件里去(同所有含有敏感信息的文件一样), 所以要添加到.gitignore

创建 GitHub Repo

  1. 登录到 GitHub
  2. 点击 New repository
  3. 填写 Repo name, 选择 Public

然后使用 fastlane 管理存在 GitHub 的证书和 Provisioning Profile

生成证书和 Provisioning Profile

每个项目也只需执行一次这样的操作。

desc "Create all new provisioning profiles managed by fastlane match"
lane :create_new_profiles do
  api_key = get_app_store_connect_api_key
  keychain_name = "TemporaryKeychain"
  keychain_password = "TemporaryKeychainPassword"
  create_keychain(
    name: keychain_name,
    password: keychain_password,
    default_keychain: false,
    timeout: 3600,
    unlock: true,
  )
  match(
    type: "adhoc",
    keychain_name: keychain_name,
    keychain_password: keychain_password,
    storage_mode: "git",
    git_url: "https://github.com/JakeLin/moments-codesign",
    app_identifier: "com.ibanimatable.moments.internal",
    team_id: "6HLFCRTYQU",
    api_key: api_key
  )
  match(
    type: "appstore",
    keychain_name: keychain_name,
    keychain_password: keychain_password,
    storage_mode: "git",
    git_url: "https://github.com/JakeLin/moments-codesign",
    app_identifier: "com.ibanimatable.moments",
    team_id: "6HLFCRTYQU",
    api_key: api_key
  )
end

使用, 执行这个语句即可:

fastlane create_new_profiles

上面的脚本中调用了两个方法:

  1. create_keychain(),是为了把证书和描述文件存到 keychain 里
  2. match(),显然是用来生成证书和描述文件的,type指定了渠道
  3. get_app_store_connect_api_key这句话显然是取app sotre的apikey, 只是注意一下, 无参数调方法似乎都连括号都不需要

方法定义如下:

desc 'Get App Store Connect API key'
  private_lane :get_app_store_connect_api_key do
    key_content = ENV["APP_STORE_CONNECT_API_CONTENT"]
    api_key = app_store_connect_api_key(
      key_id: "D9B979RR69",
      issuer_id: "69a6de7b-13fb-47e3-e053-5b8c7c11a4d1",
      key_content: "-----BEGIN EC PRIVATE KEY-----\n" + key_content + "\n-----END EC PRIVATE KEY-----",
      duration: 1200,
      in_house: false
    )
    api_key 
  end

从环境变量里取出apikey, 用app_store_connect_api_key方法来获取临时的 App Store Connect API Key, 其中,key_id和issuer_id的值都可以在 App Store Connect 的 Keys 配置页面上找到。这些属于ID, 不属于鉴权信息, 不存在什么保存和脱敏, 直接写到方法里就行了(除非你要管理多个账号)

调github可能会碰到用户的问题, 可以提前设置一下:

$> git config --global user.email "[email protected]"
$> git config --global user.name "Moments CI"

当create_new_profiles命令成功执行以后,你可以在私有 Repo 上看到两个新的文件夹certs/distributionprofiles, 其中,certs 文件夹用于保存私钥(.p12)和证书(.cer)文件,而 profiles 文件夹则用来保存 adhoc 和 appstore 两个 Provisioning Profile 文件。你也可以在苹果开发者网站查看新的证书文件和 Provisioning Profile 文件

下载证书和 Provisioning Profile

一个项目只需要执行一次生成证书和 Provisioning Profile 的操作,其他团队成员可通过fastlane download_profiles命令来下载证书和 Provisioning Profile。该 Lane 的代码如下:

desc "Download certificates and profiles"
lane :download_profiles do
  keychain_name = "TemporaryKeychain"
  keychain_password = "TemporaryKeychainPassword"
  create_keychain(
    name: keychain_name,
    password: keychain_password,
    default_keychain: false,
    timeout: 3600,
    unlock: true,
  )
  match(
    type: "adhoc",
    readonly: true,
    keychain_name: keychain_name,
    keychain_password: keychain_password,
    storage_mode: "git",
    git_url: "https://github.com/JakeLin/moments-codesign",
    app_identifier: "com.ibanimatable.moments.internal",
    team_id: "6HLFCRTYQU"
  )
  match(
    type: "appstore",
    readonly: true,
    keychain_name: keychain_name,
    keychain_password: keychain_password,
    storage_mode: "git",
    git_url: "https://github.com/JakeLin/moments-codesign",
    app_identifier: "com.ibanimatable.moments",
    team_id: "6HLFCRTYQU"
  )
end

与生成证书的描述文件的方法好像只差了一个readonly: true参数. 我不知道match里做了什么, 但显然这里就是告诉你去git里把对应的配置文件下载下来, 再自行一个create_keychain方法存到本地

新增设备

当我们通过 Ad Hoc 的方式来分发 App 时,必须把需要安装 App 的设备 ID 都添加到设备列表里面,你可以在苹果开发者网站的“Certificates, Identifiers & Profiles”的 Devices 下查看所有设备信息。但每增加一个设备, 都要这么操作, 重新生成, 并所有人下载一次, 我们可以用fastlane来管理, 加一个add_device的方法(lane):

desc "Add a new device to provisioning profile"
lane :add_device do |options|
  name = options[:name]
  udid = options[:udid]
  # Add to App Store Connect
  api_key = get_app_store_connect_api_key
  register_device(
    name: name,
    udid: udid,
    team_id: "6HLFCRTYQU",
    api_key: api_key
  )
  # Update the profiles to Git private repo
  match(
    type: "adhoc",
    force: true,
    storage_mode: "git",
    git_url: "https://github.com/JakeLin/moments-codesign",
    app_identifier: "com.ibanimatable.moments.internal",
    team_id: "6HLFCRTYQU",
    api_key: api_key
  )
end

显然, 就是一个register加一个match, 这是第三次用到match了, 它能创建或下载. 这个时候只是在远程完成了添加, 想要本地同步的团队成员, 再执行一次下载(download_profiles)就好了

自动化构建

在上一讲我们讲述了如何使用 fastlane 来自动管理私钥、证书和 Provisioning Profile 文件。其实,我们可以自动化几乎所有的 iOS 任务,包括编译、检查代码风格、执行测试、打包和签名、发布到分发渠道、上传到 App Store、发送发布通知等。自动化是衡量一个团队成熟度的关键因素,也是推动项目工程化实践的基石

编译与执行测试

desc "Build development app"
lane :build_dev_app do
  puts("Build development app")
  gym(scheme: "Moments",
    workspace: "Moments.xcworkspace",
    export_method: "development",
    configuration: "Debug",
    xcargs: "-allowProvisioningUpdates")
end

现在用了gym这个方法(action), 注意export_method: "development", 这就跟xcode上配置使用自动签名是一个意思, 可以省去配置证书和描述文件.

scan来执行测试:

desc "Run unit tests"
lane :tests do
  puts("Run the tests")
  scan(
    scheme: "Moments", 
    output_directory: "./fastlane/dist", 
    output_types: "html", 
    buildlog_path: "./fastlane/dist")
end

打包与签名

苹果公司为了给所有的 iOS 用户提供安全和一致的体验,便把所有的 App 都放在沙盒(Sandbox)里面运行,这样能保证 App 运行在一个受限和安全的空间里面。通常情况下,App 只能访问沙盒里面的文件系统。当 App 需要访问系统资源的时候,必须通过权限管理模块的授权。

我们以获取地理位置信息作为例子来看看权限管理系统的运作方式。当 App 想要获得后台地理位置信息时,

  • 权限管理系统会检查 Info.plist 文件是否提供了描述信息,
  • 并检查用户是否同意,
  • 最后检查 Background Modes 的 Entitlement 是否允许 Location updates。

如果这些都通过了,权限管理系统就允许 App 在后台访问地理位置信息。任何一项不通过,App 都无法在后台访问地理位置信息。

当 App 需要访问各种资源的时候,iOS 系统会询问 App 一些重要的问题来判断是否能通过权限检查。那谁能提供这些信息呢?答案是 Provisioning Profile。可以这么说,Provisioning Profile 能回答下面的几大“哲学”问题。

  • 你是谁? Provisioning Profile 具有 Team ID 等信息,iOS 能知道这个 App 的开发者是谁。
  • 你要干吗? Provisioning Profile 关联的 Entitlement 能告诉 iOS 系统该 App 需要访问哪些系统资源。
  • 你要去哪里? Provisioning Profile 里的设备列表能告诉 iOS 系统能否安装该 App。
  • 我能相信你吗? 这涉及签名(Code Sign)的概念,通过签名,就能证明你是这个 App 的签名主体,并能证明这个 App 里面没有经过非法更改。

前三个问题, 答案都在描述文件里, 那如果我们把描述文件一换不就行了吗? 签名就是起的这个作用, 保证该应用与原证书持有人提交时的那个没有变更. 签名原理可以去看看别的文章, 总之, 如果你自己签名, 有如下两种方式, 首先都是把描述文件下载下来安装(前面的download_profiles也行), 然后:

  • 方法一: 使用 Xcode 的 Archive 菜单进行打包,然后再使用 Validate App 功能来签名
  • 方法二: 使用xcodebuild archive命令来生成 .xcarchive 文件,然后调用xcodebuild -exportArchive命令来生成 IPA 文件

我们来看自动化方案:

desc 'Creates an archive of the Internal app for testing'
lane :archive_internal do
  unlock_keychain(
    path: "TemporaryKeychain-db",
    password: "TemporaryKeychainPassword")
  update_code_signing_settings(
    use_automatic_signing: false,
    path: "Moments/Moments.xcodeproj",
    code_sign_identity: "iPhone Distribution",
    bundle_identifier: "com.ibanimatable.moments.internal",
    profile_name: "match AdHoc com.ibanimatable.moments.internal")
  puts("Create an archive for Internal testing")
  gym(scheme: "Moments-Internal",
    workspace: "Moments.xcworkspace",
    export_method: "ad-hoc",
    xcargs: "-allowProvisioningUpdates")
  update_code_signing_settings(
    use_automatic_signing: true,
    path: "Moments/Moments.xcodeproj")
end

我们定义了archive_internalLane 来打包和签名 Moments App 的 Internal 版本,具体分成以下四步。

  • 第一步是解锁 Keychain。因为签名所需的证书信息保存在 Keychain 里面,所以我们需要解锁 Keychain 来让 fastlane 进行访问。
  • 第二步是更新签名信息。我们使用“iPhone Distribution”作为签名主体,并使用“match AdHoc com.ibanimatable.moments.internal”作为 Provisioning Profile,这表示我们使用了 Ad Hoc 的 Provisioning Profile 来分发该 App。
  • 第三步是核心操作,调用gymAction 来进行打包和签名。gym帮我们封装了xcodebuild的实现细节,我们只需要调用一个 Action 就能完成打包和签名的操作。这里需要注意,为了生成用于测试的 Internal App,我们需要把export_method参数赋值为ad-hoc,这样我们就能实现内部分发。
  • 第四步是恢复回自动签名。因为在开发环境中,我们使用的是自动签名。为了方便本地开发,在完成打包后,我们得把签名方式进行重置。

下面再看一下如何为 App Store 版本的 App 进行打包和签名。

desc 'Creates an archive of the Production app with Appstore distribution'
lane :archive_appstore do
  unlock_keychain(
    path: "TemporaryKeychain-db",
    password: "TemporaryKeychainPassword")
  update_code_signing_settings(
    use_automatic_signing: false,
    path: "Moments/Moments.xcodeproj",
    code_sign_identity: "iPhone Distribution",
    bundle_identifier: "com.ibanimatable.moments",
    profile_name: "match AppStore com.ibanimatable.moments")
  puts("Create an archive for AppStore submission")
  gym(scheme: "Moments-AppStore",
    workspace: "Moments.xcworkspace",
    export_method: "app-store",
    xcargs: "-allowProvisioningUpdates")
  update_code_signing_settings(
    use_automatic_signing: true,
    path: "Moments/Moments.xcodeproj")
end

archive_appstore的实现基本上与archive_internal一致。不同的地方是在archive_appstore里面,我们指定的 Provisioning Profile 是 “match AppStore com.ibanimatable.moments”,而且在调用gymAction 时传递了app-store给export_method参数,表示要生成上传到 App Store 的 App。

有了archive_internal和archive_appstore以后,再结合上一讲介绍的download_profiles,我们就可以十分方便地自动化打包和签名 App 了。命令执行完毕以后,在项目文件夹里面会出现一个 Moments.ipa 文件。IPA 文件也叫作 iOS App Store Package,该文件是一个包含了 iOS App 的存档(archive)文件。 为了查看 IPA 文件里面的内容,我们可以把后缀名修改成 .zip 文件并进行解压,其内容如下图所示: 在图中有一个名为 embedded.mobileprovision 的 Provisioning Profile 文件,你可以打开该文件来查看相关内容,如下图所示: 在该 Provisioning Profile 中,你可以看到用于定义访问系统资源权限的 Entitlement 信息、证书信息以及用于安装的设备列表信息。有了这些信息,iOS 系统就能对 App 进行权限管理。

上传到发布渠道

演示一个发布到firebase的lane

desc 'Deploy the Internal app to Firebase Distribution'
lane :deploy_internal do
  firebase_app_distribution(
      app: "1:374168413412:ios:912d89b30767d8e5a038f1",
      ipa_path: "Moments.ipa",
      groups: "internal-testers",
      release_notes: "A new build for the Internal App",
      firebase_cli_token: ENV["FIREBASE_API_TOKEN"]
  )
end

可见, 这个action是默认存在的, lane也没封装什么, 就是包装了一下参数, 这些参数都来自于firebase, 有需要的读一下相关文档, 有必要的话也可以把相关的key存local.keys里面读到环境变量里去

发布到App Store也一样, 直接传参即可:

desc 'Deploy the Production app to TestFlight and App Store'
lane :deploy_appstore do
  api_key = get_app_store_connect_api_key
  upload_to_app_store(
    api_key: api_key,
    app_identifier: "com.ibanimatable.moments",
    skip_metadata: true,
    skip_screenshots: true,
    precheck_include_in_app_purchases: false,
  )
end

上传到文件存储, 还是app sotore的标的, 就是上一节生成的ipa文件, 这里不需要把ipa文件路径传参进去, 说明是在同目录里搜索按打包规则生成的名字, 不能改也不能自定义的

持续集成

前面我们把一系列任何封装成了一个个命令, 差最后一步, 来调度这些命令的人, 通过CI(Continuous Integration), 你可以自行采购硬件搭建这样一个平台, 也可以使用虚拟机, 最简单的起步, 是使用云服务, 这一节但要Travis CI. (Jekins教程就不在此了)

  • Travis CI 使用了“代码即配置”的方式来配置 CI 管道,这是最重要的一个原因。我们可以把 CI 管道的配置信息都写在一个 YAML 文件里面,并保存在 GitHub 上。这样能方便我们把 CI 配置共享到多个项目,而且通过 Git 历史记录来不断对比和优化 CI 配置。除此之外,YAML 文件的配置方式已成为 CI 配置的标准,当需要升级为云端虚拟机 CI 和全手工维护 CI 时,我们可以重用 Travis CI 的 YAML 文件。相比之下,有些 CI 需要在网页上进行手工配置,而且无法看到修改历史,这使得我们无法通过代码把配置信息共享到其他项目中去。
  • Travis CI 免费给开源项目使用。
  • Travis CI 整合了 GitHub 和 GitLab 等代码管理平台,只需要一次授权就能整合 CI 服务。
  • Travis CI 支持多个不同版本的 Mac OS 和 Xcode,我们可以根据项目的要求来灵活选择不同的版本。例如通过 Travis CI,我们可以方便地测试 Xcode Beta 版的构建情况。
  1. 连接 Travis CI 与 GitHub
  2. 配置 .travis.yml
language: swift
osx_image: xcode12.2
env:
  global:
    - CI_BUILD_NUMBER=${TRAVIS_BUILD_NUMBER}
before_install:
  - bundle install
  - bundle exec pod install

TRAVIS_BUILD_NUMBER 的值由 Travis CI 系统所提供,它能帮助我们生成一个自增的 Build Number, 这个值是怎么写到app里去的呢? 当然是修改.xcconfig文件:

会在 increment_build_number.sh 脚本中使用,如下代码所示:
VERSION_XCCONFIG="Moments/Moments/Configurations/BaseTarget.xcconfig"
SED_CMD="s/\\(PRODUCT_VERSION_SUFFIX=\\).*/\\1${CI_BUILD_NUMBER}/" # Make sure setting this environment variable before call script.
sed -e ${SED_CMD} -i.bak ${VERSION_XCCONFIG} 
rm -f ${VERSION_XCCONFIG}.bak

所有的 CI 管道都配置在jobs下面

jobs:
  include:
    - stage: "Build"
      name: "Build internal app"
      script:
        - set -o pipefail
        - echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
        - bundle exec fastlane download_profiles
        - bundle exec fastlane archive_internal

这里用到了fastlane里的命令, 显然之前配置的local.keys等用于写环境变量的, 也得部署到这个云服务里去, 其它stage示例:

# 单元测试
- stage: "Test"
  name: "Test app"
  script:
    - set -o pipefail
    - bundle exec fastlane tests

# 发测试包
- stage: "Archive, sign and deploy internal app"
  name: "Archive Internal app"
  if: branch = main
  script:
    - set -o pipefail
    - echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
    - bundle exec fastlane download_profiles
    - ./scripts/increment_build_number.sh
    - bundle exec fastlane archive_internal
    - bundle exec fastlane deploy_internal

# 发布苹果市场
- stage: "Archive, sign and deploy production app"
  name: "Archive Production app"
  if: branch = release
  script:
    - set -o pipefail
    - echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
    - bundle exec fastlane download_profiles
    - ./scripts/increment_build_number.sh
    - bundle exec fastlane archive_appstore
    - bundle exec fastlane deploy_appstore

统计分析

崩溃报告

感兴趣的话了解下, 这是使用firebase的Crashlytics

自动化上传dSYM文件

当 Xcode 在把源代码编译成机器码的时候,编译器会生成一堆 Symbol(符号)来存放类型的名字、全局变量和方法的名称等,这些 Symbol 会把机器码对应到各种类型所在的文件和行号。因此,我们可以利用这些 Symbol 在 Xcode 里面进行 Debug,或者在崩溃报告上定位 Bug。默认情况下,当我们生成一个 Debug 版本的 App 时,所有的 Debug Symbol 都会自动存放在 App 里面。 但是 Release 版本的 App 却不一样,为了减小 App 的尺寸,编译器并不把 Debug Symbol 存放在 App 里面,而是生成一些额外的 dSYM 文件(Debug Symbol file)来存放。每个可执行文件、Framework 以及 Extension 都通过唯一的 UUID 来配对相应的 dSYM 文件。为了便于定位线上 App 的问题,我们需要保存这些 dSYM 文件,并上传到崩溃报告服务上去。 幸运的是,fastlane 提供了一个upload_symbols_to_crashlyticsAction 来帮我们简化上传 dSYM 文件的操作。上传 Internal App dSYM 文件的具体实现如下:

desc 'Upload symbols to Crashlytics for Internal app'
lane :upload_symbols_to_crashlytics_internal do
  upload_symbols_to_crashlytics(
    dsym_path: "./Moments.app.dSYM.zip",
    gsp_path: "./Moments/Moments/Configurations/Firebase/GoogleService-Info-Internal.plist",
    api_token: ENV["FIREBASE_API_TOKEN"]
  )
end

在调用 upload_symbols_to_crashlyticsAction 时,我们需要传递三个参数:

  • 首先把 dSYM 文件的路径传递给dsym_path参数,
  • 然后把 Firebase 的配置文件传递给gsp_path参数,
  • 最后是把 Firebase API Token 传递给api_token参数

接下来我们再一起看看上传 AppStore 版本 dSYM 文件的具体实现:

desc 'Upload symbols to Crashlytics for Production app'
lane :upload_symbols_to_crashlytics_appstore do
  upload_symbols_to_crashlytics(
    dsym_path: "./Moments.app.dSYM.zip",
    gsp_path: "./Moments/Moments/Configurations/Firebase/GoogleService-Info-AppStore.plist",
    api_token: ENV["FIREBASE_API_TOKEN"]
  )
end

几乎是一样的, 差别就在于firebase的配置文件是不同的. 有了这些 Lane 以后,我们就可以修改 CI 的配置来自动完成上传 dSYM 文件的操作。下面是 .travis.yml 的配置:

- stage: "Archive, sign and deploy internal app"
  name: "Archive Internal app"
  if: branch = main
  script:
    - bundle exec fastlane archive_internal
    - bundle exec fastlane upload_symbols_to_crashlytics_internal # 新增的步骤
    - bundle exec fastlane deploy_internal

可以看到,我们在script下增加了 upload_symbols_to_crashlytics_internal 步骤, 而且是先上传再deploy。

查看崩溃报告

性能报告

远程开关

通过远程开关,我们就可以在无须发布新版本的情况下开关 App 的某些功能,甚至可以为不同的用户群体提供不同的功能。 远程功能开关能帮助我们快速测试新功能,从而保证产品的快速迭代。该模块主要由两部分所组成:Remote Config 模块和Toggle 模块。

Remote Config 也叫作“远程配置”,它可以帮助我们把 App 所需的配置信息存储在服务端,让所有的 App 在启动的时候读取相关的配置信息,并根据这些配置信息来调整 App 的行为。 Remote Config 应用广泛,可用于远程功能开关、 A/B 测试和强制更新等功能上。一些基础功能演示:

protocol RemoteConfigProvider {
    func setup()
    func fetch()
    func getString(by key: RemoteConfigKey) -> String?
    func getInt(by key: RemoteConfigKey) -> Int?
    func getBool(by key: RemoteConfigKey) -> Bool
}

具体实现是本地存储和远端拉取的结合, 这一节太个性化, 也略过了. 教程中仍是以firebase为例来讲解的, 因为省去了开发页面的过程, firebase原生就支持(其实就等于firebase也提供了几个restful服务供你调用而已, 值都是你自己在后台配的, 自行去使用)

在使用的地方, 使用本地存储里的值来进行判断即可. 所以拉取远程配置一般是在app启动, 或当前页面加载的时机(如果是当前页面加载, 那么所有的UI更新都得在这个拉取之后, 最好别这么干)

AB测试

原理跟远程开关一致, 多了个分组. 需要一个平台能定义分组和关键值, app里去应用这些值来写针对的代码, 同样firebase也是支持的. 定向推送, 随机推送等机制, 也就一起提供给你了, 可见如果愿意使用firebase的话, 几乎是一站式的. 当然如果是公司开发, 这种事由后端同学做个平台也不是不可以.

App Icon制作

使用SwiftUI替换UIKit

如果严格按ViewModel数据意向流动的方式编写的代码(如本教程), 这一步重写一个视图层就行了. 关于SwiftUI, 可以跳转这篇专题文章Swift

SwiftUI 的状态管理

iOS17开始已经大大简化了SwiftUI的状态管理, 使用@Observation, 参考文章

struct ContentView: View {
    @State private var age = 20
    var body: some View {
        Button("生日啦,现在几岁: \(age)") {
            age += 1
        }
    }
}

上述例子实现了本视图里的状态联动, 那么如何跨页面共享呢? 那就需要使用到 @StateObject@ObservedObject 属性包装器了。这两个属性包装器所定义的属性都必须遵循ObservableObject协议。

class UserObservableObject: ObservableObject {
    var name = "Jake"
    var age = 20 {
        willSet {
            objectWillChange.send()
        }
    }
}

你们也看到了send方法, 但是写法很繁, 每一个能监听的属性都要在 willSet 方法里send的话那将是一个灾难. 所以有了简化版本

class UserObservableObject: ObservableObject {
    var name = "Jake"
    @Published var age = 20
}

同时要注意, 所有遵循 ObservableObject 协议的子类型都必须是引用类型,所以我们只能使用类而不是结构体(Struct). 介绍完ObservableObject协议以后,我们就可以通过下面的例子看看如何使用 @StateObject (Private)@ObservedObject (Private) 属性包装器了。

struct ChildView: View {
    @ObservedObject var user: UserObservableObject
    var body: some View {
        Button("生日啦,现在几岁: \(user.age)") {
            user.age += 1
        }
    }
}
struct ParentView: View {
    @StateObject var user: UserObservableObject = .init()
    var body: some View {
        VStack {
            Text("你的名字:\(user.name)")
            ChildView(user: user)
        }
    }
}

@StateObject (Private)@ObservedObject (Private) 都可以定义用于状态共享的属性,而且这些属性的类型都必须遵循ObservableObject协议。不同的地方是 @StateObject (Private) 用于生成和管理状态属性的生命周期,而 @ObservedObject (Private) 只能把共享状态从外部传递进来。例如,在上面的示例代码中,我们在ParentView里使用 @StateObject (Private) 来定义并初始化user属性,然后传递给ChildView的user属性。由于ChildView的user属性来自外部的ParentView,因此定义为 @ObservedObject。 (Private)

当我们需要共享状态的时候,

如果只有两层关系还是很方便的,但假如有好几层的父子关系,逐层传递会变得非常麻烦,那有没有好办法解决这个问题呢? @EnvironmentObject (Private) 就是用于解决这个问题的。@EnvironmentObject 能帮我们把状态共享到整个 App 里面,下面还是通过一个例子来看看。

@main
struct MomentsApp: App {
    @StateObject var user: UserObservableObject = .init()
    var body: some Scene {
        WindowGroup {
            ParentView()
                .environmentObject(user)
        }
    }
}
struct ChildView: View {
    @EnvironmentObject var user: UserObservableObject
    var body: some View {
        Button("生日啦,现在几岁: \(user.age)") {
            user.age += 1
        }
    }
}
struct ParentView: View {
    var body: some View {
        VStack {
            ChildView()
        }
    }
}

@EnvironmentObject (Private) 有点像 Singleton,我们不能过度使用它,否则会增加模块间的耦合度。@ObservedObject 与 @EnvironmentObject 都能帮助我们共享引用类型的属性,但如何共享值类型的属性呢?

@Binding (Private) 属性包装器就能帮我们定义共享值类型的属性。

struct ChildView: View {
   @Binding var isPresented: Bool
   var body: some View {
       Button("关闭") {
           isPresented = false
       }
   }
}
struct ParentView: View {
   @State private var showingChildView = false
   var body: some View {
       VStack {
           Text("父 View")
       }.sheet(isPresented: $showingChildView) {
           ChildView(isPresented: $showingChildView)
       }
   }
}

可见, 在定义的类里使用, 和往下传, 是加了$符号的, 这使得showingChildView能被回写

SwiftUI 的架构与实现

桥接 RxSwift 与 SwiftUI

为了把这些 ViewModel 类型桥接到 SwiftUI 版本的 View 模块,我们增加了两个类型:MomentsListObservableObject和IdentifiableListItemViewModel。MomentsListObservableObject负责给 SwiftUI 组件发送更新消息,下面是它的具体实现:

final class MomentsListObservableObject: ObservableObject {
    private let viewModel: MomentsTimelineViewModel
    private let disposeBag: DisposeBag = .init()
    @Published var listItems: [IdentifiableListItemViewModel] = []
    init(userID: String, momentsRepo: MomentsRepoType) {
        viewModel = MomentsTimelineViewModel(userID: userID, momentsRepo: momentsRepo)
        setupBindings()
    }
    func loadItems() {
        viewModel.loadItems()
            .subscribe()
            .disposed(by: disposeBag)
    }
    private func setupBindings() {
        viewModel.listItems
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] items in
                guard let self = self else { return }
                self.listItems.removeAll()
                self.listItems.append(contentsOf: items.flatMap { $0.items }.map { IdentifiableListItemViewModel(viewModel: $0) })
            })
            .disposed(by: disposeBag)
    }
}

写到这里其实够了, 原来的那个viewmodel是不能直接用的, 还得再包一层. 剩下的是把所有的viewmodel都包一次, 以及用SwiftUI来重写视图, 就不再说了.