iOS架构进阶(一)

这也是我不想用简书的原因, 能发几篇, 能发多长, 能发什么内容全控制不了, 还是本地大法好。 本文是一个系列课程的学习笔记, 全文23646字, 我试了下, 至少得切成两篇来发布.

Ruby工具链

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

\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

image.png
  • 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。


    image.png

    image.png
  • 要实现上面三个环境打包出不同的名字,从这个路径,就可以为每个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 历史很难查看和对比。


    image.png
  • 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"

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


image.png

我们把所有 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)后面的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 上引用它们。


    image.png

可以在 build Settings 页面来查看具体的生效值(all + level), 5列就对应的5个优先级(越往左越高,所以resolved就代表解析值的意思)


image.png

总结下


image.png

最后

  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']
image.png
  • .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

image.png

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.)
image.png

统一的设计规范

间距

  • 只保留几个几个命名间距,如下面是开源设计规范 Backpack 所定义的间距,其包含了 iOS、 Android 和 Web 三个平台。


    image.png
// 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文件关注几个设置,主要是版本和路径相关
    image.png
  1. 手动创建文件
DesignKit        
  DesignKit.podspec
  assets/
  src/
  LICENSE        
  • pod spec lint命令检测spec文件正确性
  • 使用路径指定内部库
pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false
  • pod install后:
    image.png

功能开关组件

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

\begin{cases} 编译时开关 \\ 本地开关 \\ 远程开关 \end{cases}

实现思路:


image.png
  • 做一个开关协议,最终实现三种开关,每种开关根据自己的特征开放接口
  • 做一个开关的行为类,主要实现判断和更新
    • 比如使用本地编译标识的话,"更新"方法就不需要实现了
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']
image.png

这里要注意,由于我们自己的源代码会使用到 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
image.png

第三方字体要与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)供我们使用。


image.png

上图是苹果开发者网站提供的一个 iOS 系统色,有蓝色、绿色、靛蓝、橙色、黄色等,它们在浅色模式和深色模式下会使用到不同的颜色值。


image.png

上图显示是 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

image.png

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


image.png

响应式编程与RxSwift

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


    image.png
  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 模块。

image.png

底层 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

\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 的数据与状态。


    image.png
  • 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(操作符)
    image.png

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.|languages.ios.rxSwift#map]]

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容