这也是我不想用简书的原因, 能发几篇, 能发多长, 能发什么内容全控制不了, 还是本地大法好。 本文是一个系列课程的学习笔记, 全文23646字, 我试了下, 至少得切成两篇来发布.
Ruby工具链
目前流行的第三方工具 CocoaPods
和 fastlane
都是使用 Ruby
来开发的。特别是 Ruby
有非常成熟的依赖库管理工具 RubyGems
和 Bundler
,其中 Bundler
可以帮我们有效地管理 CocoaPods
和 fastlane
的版本。
- 推荐使用
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
- 安装正确的 ruby 版本
- 用 ruby gem 安装 bundle
- 用 bundle 安装 cocoapods
- 用 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生成的
- 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用来定义如何构建出一个产品(例如
App
,Extension
或者Framework
)- Target 可以指定需要编译的源代码文件和需要打包的资源文件,以及构建过程中的步骤。
- 那么 Target 所指定的设置哪里来的呢?来自 Build Settings。
- Build Setting保存了构建过程中需要用到的信息,它以一个个变量(键值对)的形式而存在,例如所支持的设备平台,或者支持操作系统的最低版本等。
推荐使用 Build Configuration
和 Xcode 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)"
- 可见是到target的building setting里搜(设)的。
- Build settings reference官方文档
- Xcode Build Settings三方文档
- 但其实显示名用的是
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)后面的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就代表解析值的意思)
总结下
最后
- 如果选择用xcconfig来文本化配置,那么千万不要再在UI上来修改(会覆盖)
- 图标,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 创建和管理这个内部库,有两种方法(命令/手动)
-
pod lib create [pod name]
, 如DesignKit
会生成如下文件:
DesignKit
Example/
README.md
DesignKit.podspec
LICENSE
_Pods.xcodeproj
- 会自动包含一个工程和一个示例项目,
-
podspec
文件关注几个设置,主要是版本和路径相关
- 手动创建文件
DesignKit
DesignKit.podspec
assets/
src/
LICENSE
-
pod spec lint
命令检测spec文件正确性 - 使用路径指定内部库
pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false
-
pod install
后:
功能开关组件
- 如果一个大功能,直接做一个分支管理开发过程既漫长,也无法细粒度地维护,可以把它拆成很多小功能,依次提交
- 但是会有一个问题,几个小功能提交后碰到一次发布的话,会把主体未开发完的小功能也发布了上去,因此可以考虑对功能进行“开关”
- 先做个开关,直到开发完毕再移除它,或者在所有代码合并完毕后,从主分支再拉一个
remove-toggle
这样的分支,专门用来移除它。 - 当然,把小功能往一个主功能分支提也是个思路,非本节内容
- 先做个开关,直到开发完毕再移除它,或者在所有代码合并完毕后,从主分支再拉一个
实现思路:
- 做一个开关协议,最终实现三种开关,每种开关根据自己的特征开放接口
- 做一个开关的行为类,主要实现判断和更新
- 比如使用本地编译标识的话,"更新"方法就不需要实现了
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
- 表示这是个strings的任务
- 来源是xxx/en.lproj (因为多语言code是一样的,所以指定一个就好了)
- 后面就是模板文件和输出位置,模板文件是需要自己多留意一下的。
生成类似的代码:
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")
}
}
- 使用了
a.b
这样的用点分隔的code,好处理可以归类 - 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
, 目前比较流行的响应式编程框架还有ReactiveKit
、ReactiveSwift
和 Combine -
RxSwift 遵循了 ReactiveX 的 API 标准,由于 ReactiveX 提供了多种语言的实现,学会 RxSwift 能有效把知识迁移到其他平台。
- 当用户打开朋友圈页面,App 会使用后台排程器向 BFF 发起一个网络请求,
- Networking 模块把返回结果通过Observable 序列发送给 Repository 模块。
- Repository 模块订阅接收后,把数据发送到Subject里面,
- 然后经过map 操作符转换,原先的 Model 类型转换成了 ViewModel 类型。
- ViewModel 模块订阅经过操作符转换的数据,发送给下一个Subject,
- 之后,这个数据被 ViewController 订阅,并通过主排程器更新了 UI。
整个过程中,Repository 模块、 ViewModel模块、ViewController 都是订阅者,分别接收来自前一层的信息。就这样,当 App 得到网络返回数据时,就能自动更新每一层的状态信息,也能实时更新 UI 显示。
异步数据序列 Observable
- 为了保证程序状态的同步,我们需要把各种异步事件都发送到异步数据流里,供响应式编程发挥作用。
- 在 RxSwfit 中,异步数据流称为 Observable 序列,它表示可观察的异步数据序列,也可以理解为消息发送序列。
- 在实际应用中,我们通常使用 Observable 序列作为入口,把外部事件连接到响应式编程框架里面。
那么怎样创建 Observable 序列呢?为方便我们生成 Observable 序列, RxSwfit 的Observable类型提供了如下几个工厂方法:
-
just
方法,用于生成只有一个事件的 Observable 序列; -
of
方法,生成包含多个事件的 Observable 序列; -
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
由于之前讲过的of
和from
等方法都不能发出error
和completed
事件 ,在这里我就使用了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 一般有
PublishSubject
、BehaviorSubject
和 ``ReplaySubject`。它们的区别在于订阅者能否收到订阅前的事件:- PublishSubject:如果你想订阅者只收到订阅后的事件,可以使用 PublishSubject。
- BehaviorSubject:如果你想订阅者在订阅时能收到订阅前最后一条事件,可以使用 BehaviorSubject。
- ReplaySubject:如果你想订阅者在订阅的时候能收到订阅前的 N 条事件,那么可以使用 ReplaySubject。
- 而在订阅以后,它们的行为都是一致的,当 Subject 发出error或者completed事件以后,订阅者将无法接收到新的事件
- 就后面的实例,是
subscribe(Subject)
,即把subject传进subscribe里来实现中转
操作符
操作符(Operator)是 RxSwift 另外一个重要的概念,它能帮助订阅者在接收事件之前把 Observable 序列中的事件进行过滤、转换或者合并。
- 转换:
map
- 过滤:
filter
,distinctUntilChanged
- 合并:
startWith
,concat
,merge
,combineLatest
和zip
除了上面提到过的常用操作符,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
来控制所执行并发任务的最大数量。 - 它可以用于下载大文件。
- 这个排程器一般用于执行繁重的后台任务,并通过设置
-
- 串行的排程器包括 CurrentThreadScheduler、MainScheduler、SerialDispatchQueueScheduler。
Observable.of(1, 2, 3, 4)
// 发布事件要执行的代码放到后台
.subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background))
.dumpObservable()
.map { "\(getThreadName()): \($0)" }
// 订阅事件要执行的代码放到主线程
.observeOn(MainScheduler.instance)
.dumpObserver()
.disposed(by: disposeBag)
这是常用的模式,比如网络请求在后台,拿到数据后更新UI肯定是在主线程
后台+异步,不能保证执行顺序,比如几个网络请求,你并不能保证一定最先处理哪一个的返回
经验:
- 当我们拿到需求的时候,先把任务进行分解,找出哪个部分是事件发布者,哪部分是事件订阅者,例如一个新功能页面,网络请求部分一般是事件发布者,当得到网络请求的返回结果时会发出事件,而 UI 部分一般为事件订阅者,通过订阅事件来保持 UI 的自动更新。
- 找到事件发布者以后,要分析事件发布的频率与间隔。如果只是发布一次,可以使用Obervable;如果需要多次发布,可以使用Subject;如果需要缓存之前多个事件,可以使用 ReplaySubject。
- 当我们有了事件发布者和订阅者以后,接着可以分析发送和订阅事件的类型差异,选择合适的操作符来进行转换。我们可以先使用本讲中提到的常用操作符,如果它们还不能解决你的问题,可以查看 RxMarbles 来寻找合适的操作符。
- 最后,我们可以根据事件发布者和订阅者所执行的任务性质,通过排程器进行调度。例如把网络请求和数据缓存任务都安排在后台排程器,而 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>
}
- 只提供了一个POST方法
- 统一用
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()
}
}
}
- 我们首先使用Observable.create()方法来创建一个 Observable 序列并返回给调用者
- 然后在create()方法的封包里使用 Alamofire 的request()方法发起网络请求。
- 为了不阻挡 UI 的响应,我们把该请求安排到后台队列中执行。
- 当我们得到返回的 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
}
}
}
"""
}
- 这不是给swift看的,所以身份只是一个字符串。
- 可以理解为写
SQL
,也是字符串
- 不要被反复使用的
getMomentsDetailsByUserID
迷惑了,名字可以变的,不需要内外层保持一致,但需要与调用者保证一致 - 这里表示这个接口返出来的json是由
getMomentsDetailsByUserID
引导的,包含了两个键(userDetails
和moments
)与前面的果断对照看一下 - query里的变量来自
variables
变量,所以params需要两个参数 - 本质上是把
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
- 在 Swift 4 之前,我们需要使用
JSONSerialization
来反序列化 JSON 数据,然后把每一个属性单独转换成所需的类型。 - 后来出现
SwiftyJSON
等库,帮我们减轻了一部分 JSON 转型工作,但还是需要大量手工编码来完成映射。 - 1Swift 4 以后,出现了
Codable
协议,我们只需要把所定义的 Model 类型遵守该协议,Swift 在调用JSONDecoder
的decode
方法时就能自动完成转型。这样既能减少编写代码的数量,还能获得原生的性能。
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 模块分成两大部分: Repo 和 DataStore。
- 其中 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 的接口,我们可以很方便地替换不同的本地数据库。
- 其中 Repo 负责统一管理数据(如访问网络的数据、读写本地数据),并通过 Subject 来为订阅者分发新的数据。
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
属性为RxSwfit
的ReplaySubject
类型。它负责把数据的更新事件发送给订阅者。-
在init()方法中,我们通过了
Key
来订阅UserDefaults
里的数据更新,- 一旦与该 Key 相关联的数据发生了变化,我们就使用
JSONDecoder
来把更新的数据解码成MomentsDetails
类型, - 然后发送给
momentsDetailsSubject
属性。 - 这样
momentsDetails
属性就可以把数据事件中转给外部的订阅者了。
MomentsRepoType
- 一旦与该 Key 相关联的数据发生了变化,我们就使用
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
必然来自于上面的DataStore
的momentsDetails
属性:
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
模块里面,大量使用了 RxSwift
的 Subject
来中转数据事件。 回顾一下,在 RxSwift
里面,常见的 Subject
有PublishSubject
、BehaviorSubject
和 ReplaySubject
。
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:PublishRelay 和 BehaviorRelay,它们的名字和 BehaviorSubject 和 ReplaySubject 非常类似,区别是 Relay 只中继next事件,我们并不能往 Relay 里发送completed或error事件。
下面是一些在项目场景中使用 Subject 的经验,希望对你有帮助。
- 如果需要把 Subject 传递给其他类型发送消息,例如在朋友圈时间轴列表页面把 Subject 传递给各个朋友圈子组件,然后接收来自子组件的事件。 这种情况我们一般会传递 PublishSubject,因为在传递前在主页面(如例子中的朋友圈时间轴页面)已经订阅了该 PublishSubject,子组件所发送事件,主页面都能收到。
- BehaviorSubject 可用于状态管理,例如管理页面的加载状态,开始时可以把 BehaviorSubject 初始化为加载状态,一旦得到返回数据就可以转换为成功状态。
- 因为 BehaviorSubject 必须赋予初始值,但有些情况下,我们并没有初始化,如果使用 BehaviorSubject 必须把其存放的类型定义为 Optional 类型。为了避免使用 Optional,我们可以使用 bufferSize 为 1 的 ReplaySubject 来代替 BehaviorSubject。
- 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 }
}
注意, 这里把loadItems
和listItems
分开了, 盲猜下, 一个是行为, 一个是组织数据, 具体得到代码里找一下使用方法. 如果不这么做, 我们可能直接observe loadItems()
的结果作为列表数据
extension ListViewModel {
var hasContent: Observable<Bool> {
return listItems
.map(\.isEmpty) // 映射为了bool
.distinctUntilChanged() // 只有值发生改变时才发送新事件
.asObservable()
}
}
- 上面提供了一个
hasContent
的默认实现 - 这个方法使用
map
和distinctUntilChanged
操作符来把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
协议,因此需要实现了该协议中listItems
和hasError
属性以及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 操作符的转换过程中,我们分别使用了
UserProfileListItemViewModel
和MomentListItemViewModel
结构体来转换用户简介信息和朋友圈条目信息。 - 这两个结构体都遵循了
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]]