26 持续集成:如何实现无需人手的快速交付?
在前面的课程中,我们已经讲过如何使用 fastlane 来自动化常用的操作,例如管理私钥、证书和 Provisioning Profile 文件,打包和签名 App,以及把 App 上传到 App Store 等。有了这些自动化操作,我们就可以很方便地通过一条命令把 App 发布到 App Store。但有没有更好的办法做到不需要人手就能自动完成打包和上传等所有的操作呢?当然有!我们可以通过 CI,也就是持续集成(Continuous Integration)来完成这一任务。
那什么是 CI 呢?CI 是一种有效的工程实践,能帮助团队更频繁、更可靠地交付代码。我们可以利用 CI 来不断优化构建、测试和发布流程,从而保证产品的快速交付,推动工程化进程与最佳实践,并推进工程师文化的建设。
CI 的系统架构
要搭建一套完整的 CI,我们需要理解 CI 的构建流程和系统架构。首先,我们以 Moments App 为例子来看看一套完整 CI 的构建流程吧。
通过上图,我们可以看到一套 CI 流程主要由两大部分组成:GitHub 流程和 CI 管道。其中,GitHub 流程是 CI 流程的触发者,而 CI 管道是 CI 流程的执行者。
我们在《06 | 代码管理:如何使用 Git 与 GitHub 统一代码管理流程?》里讲述过管理多个分支的代码流程,在此基础上,我们可以让分支在合并的时候自动触发 CI 流程,例如,在功能分支合并到主分支时触发 CI 管道 1,当主分支合并到发布分支时触发 CI 管道 2。
那 CI 管道到底是什么呢?CI 管道是自动化软件交付的流程定义,它把多个自动化操作串联起来,并按照一定的顺序执行,最终达到从源码到交付的全自动化。例如,CI 管道 1 执行编译、运行测试、打包和签名 AdHoc 版本的 App,以及上传到 Firebase 的 App 分发服务等操作。而 CI 管道 2 执行了打包和签名 AppStore 版本的 App ,以及上传到 AppStore 这两项操作。可以看到,在整个 CI 流程中没有任何手工操作的参与,都是由 GitHub 流程自动触发的。
那没有任何人手参与,CI 管道到底在哪里执行呢?答案能在下面 CI 的系统架构图中找到。
CI 架构通常由 CI中心服务和 CI构建中介(Build Agent)所组成。当 GitHub 发生变更时会通知 CI 中心服务,中心服务会把构建任务(Build Job)调度和分发给可用的 CI 构建中介。当接收到任务时,构建中介会从 GitHub 上下载代码并按照 CI 管道的配置来执行构建任务。对,CI 构建中介就是具体的执行者。
目前流行的 CI 构建中介主要分成三大类。
-
全手工维护 CI。该类 CI 从物理主机、操作系统、Ruby 环境以及 Xcode 版本都由开发团队维护。这类型的 CI 维护成本比较高,例如,需要管理物理主机和网络联通性,需要维护操作系统的安全更新等,但同时也给了我们很大管控性和灵活性。
-
云端虚拟机 CI。该类 CI 是通过租用云 Mac 虚拟机来进行搭建,目前比较流行的 Mac 虚拟机服务提供商有亚马逊的 AWS 和 MacStadium。有了这些云服务提供商,我们就不用再进行安全补丁等常规维护了,只需选择特定的 Mac OS 版本来启动虚拟机,然后在虚拟机上执行脚本来搭建 Ruby 和 Xcode 环境即可,就如 Moments App 项目里执行 setup.sh 脚本就能完成项目搭建。
-
全服务 CI。该类 CI 不仅为我们提供了虚拟机,而且还提供了 Ruby 和 Xcode 等环境,我们只需要提供一个 CI 管道配置文件就能完成这个 App 的自动构建。
没有一类 CI 是完美的,它们都有各自的优缺点。这三类 CI 从上往下看,维护成本越来越低,但从长远来看,运行成本却越来越高。从便利性来看,下面的类型会比上面的更加易用,但同时也牺牲了灵活性。
我建议你根据团队的自身情况来选择。假如你所在的团队没有专门的人员来维护 CI,可以从全服务 CI 开始。随着项目和团队的发展,慢慢地升级为云端虚拟机 CI 和全手工维护 CI。作为一个只有一个开发者的开源项目,Moments App 也选择了全服务 CI。
下面我们就以 Moments App 为例子,看看如何配置一个全服务 CI。
配置 Travis CI
Moments App 选择了 Travis CI 作为全服务 CI,原因有如下四个。
-
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 版的构建情况。
连接 Travis CI 与 GitHub
要搭建 Travis CI,首先需要使用 GitHub 账户登录到 Travis CI,然后给 Travis CI 授权,如下图所示:
然后在 Travis CI 的配置页面上点击 Activate 按钮来激活 Travis CI 与 GitHub 的连接。
就这样,Travis CI 与 GitHub 的连接完成了。
假如你需要管理该连接,可以到 GitHub 的 Settings -> Applications -> Installed GitHub Apps 下点击 Configure 按钮来打开下面的管理页面。
配置 .travis.yml
完成 Travis CI 与 GitHub 的连接以后,下一步是通过 .travis.yml 文件来配置 CI 管道。.yml 也叫作 YAML 文件,全称是 YAML Ain't Markup Language (YAML 并不是标记语言)。相比其他标记语言,YAML 具有更好的可读性,非常适合来做配置文件。下面我们一起看看 Moments App 的 .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.yml 文件的开头,我们定义了项目所使用的语言以及 Xcode 的版本号。接着定义全局的环境变量CI_BUILD_NUMBER
,该变量的值来自TRAVIS_BUILD_NUMBER
,TRAVIS_BUILD_NUMBER
的值由 Travis CI 系统所提供,它能帮助我们生成一个自增的 Build Number(构建数值)。
CI_BUILD_NUMBER
会在 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
这个 Shell 会更新 BaseTarget.xcconfig 文件里面的PRODUCT_VERSION
的值。在下面 CI 管道的配置里,你将会看到如何使用该值。
最后是定义before_install
步骤,该步骤会在每一个构建任务执行前先运行。在 Moments App 的 CI 里,我们在该步骤里安装了 Bundler 和 CocoaPods 的各个依赖项。
准备工作做完以后,我们看看如何配置 CI 管道的构建任务。
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
所有的 CI 管道都配置在jobs
下面,首先看一下用于构建的Build
任务。为了让 CI 可以访问存放在 GitHub 私有 Repo 里面的私钥、证书和 Provisioning Profile 文件,我们要为 Travis CI 配置GITHUB_API_TOKEN
。在《24 | 解决打包痛点:如何统一管理 Certificates 和 Profiles?》 里,我们讲过如何获取 GitHub Access Token,假如你记不清楚了,可以回去复习一下。当拿到 GitHub Access Token 以后,就可以在 Travis CI 上的项目 Settings 页面里面进行添加,如下图所示:
同时,我们还需要把 local.keys 文件的其他环境变量一同加上。
配置好GITHUB_API_TOKEN
环境变量后,我们就可以调用 fastlane 的download_profiles
和archive_internal
来完成 Build 的步骤了。
下面看看执行测试的Test
任务。
- stage: "Test"
name: "Test app"
script:
- set -o pipefail
- bundle exec fastlane tests
执行测试的操作非常简单,只需要执行 fastlane 的tests
即可。
接着再看看打包和部署 Internal App 的任务,具体代码如下:
- 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
为了访问 GitHub 私有 Repo,我们也需要把GITHUB_API_TOKEN
配置到 .netrc 文件里面,然后就可以下载私钥、证书和 Provisioning Profile 等文件了。接着是执行increment_build_number.sh
来更新PRODUCT_VERSION
的值。因为 Info.plist 文件引用了更新过的PRODUCT_VERSION
,每次打包时,App 都具备不同的 Build Number。最后是执行 fastlane 的archive_internal
和deploy_internal
来完成打包和上传任务。
最后一个是打包和上传 Production 版本的 App 到 App Store,具体配置代码如下:
- 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
这个在步骤上与打包和部署 Internal App 的任务基本一致,不同的地方是,我们通过条件判断语句if: branch = release
来构建发布分支而不是主分支,并且最后调用的是 fastlane 的archive_appstore
和deploy_appstore
来打包和发布到 App Store。
到这里,Moments App 的 CI 就配置完毕了,其执行效果如下:
总结
在这一讲,我们讲述了 CI 的系统架构和构建流程,并且还以 Moments App 为例子讲述了如何使用 Travis CI 来搭建一套完整的 CI。在此基础上,你可以根据项目的具体需求,不断地完善 CI 管道,并推动项目的自动化与工程化建设。CI 是每个工程化团队所必备的,我建议每个团队都建立起自己的 CI 并不断优化。CI 不仅能解放所有手工操作,还能减少错误的发生,更重要的是 CI 能推动一个团队工程师文化的发展。
思考题
你可能已经注意到,在 .travis.yml 文件里面,我们都是调用 fastlane 里面的 Lane 来完成具体的构建任务,这样做有什么好处呢?
可以把你的答案写到留言区哦。从下一讲开始,我们就进入本模块的下半部分——App 上架后的优化,我会先介绍如何使用 Firebase 的统计分析服务,记得按时来听课。
源码地址
.travis.yml 文件地址:https://github.com/lagoueduCol/iOS-linyongjian/blob/main/.travis.yml
27 统计分析:如何架构灵活的统计分析服务,助力产品增长?
App 上架是产品发展的一个里程碑,同时也是产品长远发展的起点。那有没有什么方法能帮助我们持续地改善产品功能与用户体验,并制定产品发展的方向与目标呢?当然有!我们可以使用统计分析服务(Analytics)。
统计分析服务能为我们提供用户交互度指标(Engagement Metrics),这些指标包括用户会话的活跃度、活跃的设备类型以及用户留存率等。 有了这些指标,我们就能衡量每次发布的新功能是否能有效提高用户的交互频率,从而及时调整产品发展的方向。
市面上有许多统计分析服务,为了方便使用各种不同的服务,我们为 Moments App 架构了一套灵活的统计分析模块,同时使用 Firebase 作为例子来演示如何支持一种统计分析服务。之所以选择 Firebase,其主要原因有如下三个:
-
Firebase 功能齐全,除了统计分析服务以外,几乎包含了我们优化 App 所需的各种服务,例如崩溃报告、远程配置与遥控功能开关、App 分发服务、A/B 测试等;
-
免费版的 Firebase 足够使用,与功能相当的收费产品相比,能省下不少钱;
-
Firebase 配置方便,只需搭建一次就能长久使用。
配置 Firebase 服务
在讲述如何使用 Firebase 的统计分析服务前,我们先看看如何为 Moments App 配置 Firebase 的服务。
1. 创建项目
首先,我们登录到 Firebase 网站来新建一个项目。请注意,在新建的时候必须选择“Enable Google Analytics for this project”(为该项目启动统计分析服务)选项,否则将没办法使用 A/B 测试等一系列的服务。
然后在新项目里添加 App,我的做法是为开发环境、测试环境和生产环境各自添加不同的 App。下图演示了如何添加测试环境的 Internal App。
这里关键是要填写正确的 Bundle ID。你可以到各个 Target 的 xcconfig 文件里面分别找到它们的 Bundle ID,例如在 InternalTarget.xcconfig 文件里面有如下的定义:
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.internal
2. 下载 Firebase 配置文件
第二步是为三个环境的 App 分别下载 Firebase 的配置文件。当我们把三个配置文件下载完毕后,为了方便管理,可以使用“GoogleService-Info-<环境名称>.plist”的命名方式来改名,然后把所有的配置文件都拖到 Moments 项目里面,如下图所示:
同时在各个 Target 的 xcconfig 文件分别添加名叫FIREBASE_CONFIG_FILENAME
的 Build Setting,如下所示:
FIREBASE_CONFIG_FILENAME = GoogleService-Info-Development
接着在 Build Phases 上添加“Copy Firebase Config File”步骤的配置信息,并输入下面的脚本:
cp "${PROJECT_DIR}/Moments/Configurations/Firebase/${FIREBASE_CONFIG_FILENAME}.plist" "${BUILT_PRODUCTS_DIR}/${FULL_PRODUCT_NAME}/GoogleService-Info.plist"
具体配置如下图所示:
有了这个步骤的配置,Xcode 在构建的过程中就会执行。因为步骤配置里的 Shell 脚本中使用了 xcconfig 里面的FIREBASE_CONFIG_FILENAME
变量,所以当我们构建不同环境的 App 时,Xcode 会自动拷贝对应的 Firebase 配置文件到不同的 App 里面,这样就使得不同的 App 能把用户事件发送到不同的统计分析数据服务,进而保证生产环境的数据不会受到污染。
3. 安装 Firebase SDK
下载完配置文件以后,下一步是安装 Firebase SDK。官方推荐的方式是使用 CocoaPods 来安装 Firebase SDK。我们只需要把 Firebase 添加到 Podfile 即可,具体代码如下:
def thirdparty_pods
pod 'Firebase/Analytics', '= 7.0.0'
end
target 'Moments' do
...
thirdparty_pods
...
end
我们通过pod
命令来添加统计分析服务,然后把这些 Pod 通过thirdparty_pods
函数添加到 Moments target 里面,最后重新执行bundle exec pod install
命令就能完成 Firebase SDK 的安装了。
4. 初始化 Firebase 服务
安装完 Firebase SDK 后,我们还需要在 App 里面进行初始化 Firebase 服务,只需要两步,具体代码如下:
import Firebase // 1
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure() // 2
return true
}
}
第一步是通过import Firebase
语句来引入 Firebase 库,第二步是在application(_:didFinishLaunchingWithOptions:)
函数里调用FirebaseApp.configure()
函数来启动 Firebase 服务。
好了,至此我们就为 Moments App 配置好 Firebase 服务了。
统计分析模块
下面我们以 Moments App 为例子,看看如何架构与实现一个灵活的统计分析模块以及如何使用该模块。
1. 统计分析模块的架构与实现
首先,我们看一下统计分析模块的架构图,如下图所示:
这里我们从右往左看,根据依赖关系,统计分析模块由用户活动事件(Event)、事件跟踪提供者(Provider)和事件跟踪数据仓库(Repo)这三部分组成。
下面我们分别看一下它们的实现。
所有的事件类型都遵循了一个名叫TrackingEventType
的空协议(Protocol),其定义如下:
protocol TrackingEventType { }
然后就可以定义该协议的字类型。根据用途的不同,我们把事件分成以下三类:
-
ScreenviewsTrackingEvent
,用于记录页面事件; -
ActionTrackingEventType
,用于记录行为事件,例如点击了某个按钮; -
TrackingEvent
,用于记录通用的事件。
接着我们定义了TrackingProvider
协议来发送事件。该协议定义了三个方法来分别发送不同的事件,具体定义如下:
protocol TrackingProvider {
func trackScreenviews(_ event: TrackingEventType)
func trackAction(_ event: TrackingEventType)
func trackEvent(_ event: TrackingEventType)
}
其中,trackScreenviews()
用于发送页面事件,trackAction()
负责发送行为事件,而trackEvent()
用于发送通用的事件。
当要支持某种统计分析服务(例如 Firebase)的时候,我们就需要为TrackingProvider
提供一个具体的实现类型,比如,下面就是FirebaseTrackingProvider
的具体实现:
struct FirebaseTrackingProvider: TrackingProvider {
func trackScreenviews(_ event: TrackingEventType) {
guard let event = event as? ScreenviewsTrackingEvent else {
return
}
Analytics.logEvent(AnalyticsEventSelectContent, parameters: [
AnalyticsParameterScreenName: event.screenName,
AnalyticsParameterScreenClass: event.screenClass])
}
func trackAction(_ event: TrackingEventType) {
guard let event = event as? FirebaseActionTrackingEvent else {
return
}
Analytics.logEvent(AnalyticsEventSelectContent, parameters: event.parameters)
}
func trackEvent(_ event: TrackingEventType) {
guard let event = event as? TrackingEvent else {
return
}
Analytics.logEvent(event.name, parameters: event.parameters)
}
}
FirebaseTrackingProvider
遵循了TrackingProvider
协议,并实现了trackScreenviews()
、trackAction()
和trackEvent()
三个方法。在这些方法里面,都是通过guard
语句来检查输入类型是否正确,并通过 Firebase SDK 所提供的Analytics.logEvent()
方法来发送事件。
假如我们需要支持新的统计分析服务时,就可以为该服务提供一个遵循TrackingProvider
协议的实现类型。例如,当我们支持 Mixpanel 时,就可实现一个名叫MixpanelTrackingProvider
的结构体。
有了TrackingProvider
的实例以后,我们就可以通过 Repo 来管理它们。这里我们一起看一下TrackingRepoType
协议和它的实现类型TrackingRepo
。
TrackingRepoType
的实现如下:
protocol TrackingRepoType {
func register(trackingProvider: TrackingProvider)
func trackScreenviews(_ event: TrackingEventType)
func trackAction(_ event: TrackingEventType)
func trackEvent(_ event: TrackingEventType)
}
该协议定义了register(trackingProvider:)
方法来注册各种TrackingProvider
,然后还定义了三个方法来发送不同类型的事件。
TrackingRepo
的具体实现如下:
final class TrackingRepo: TrackingRepoType {
static let shared: TrackingRepo = .init()
private var providers = [TrackingProvider]()
private init() { }
func register(trackingProvider: TrackingProvider) {
providers.append(trackingProvider)
}
func trackScreenviews(_ event: TrackingEventType) {
providers.forEach { $0.trackScreenviews(event) }
}
func trackAction(_ event: TrackingEventType) {
providers.forEach { $0.trackAction(event) }
}
func trackEvent(_ event: TrackingEventType) {
providers.forEach { $0.trackEvent(event) }
}
}
TrackingRepo
实现了TrackingRepoType
协议的所有方法,其中register(trackingProvider:)
方法把各个注册的TrackingProvider
的实例都保存在providers
属性里面,而trackScreenviews()
、trackAction()
和trackEvent()
三个方法则分别调用了providers
属性所对应的方法。你可以看到,假如我们注册了多个统计分析服务的 Provider,TrackingRepo
会把每个事件依次发送给各个后台服务。
到此为止,我们就实现了一套灵活的统计分析模块。有了它,我们可以很便捷地添加或者替换不同的统计分析服务。
2. 统计分析模块的使用
要使用统计分析模块,需要两步,第一步是注册TrackingProvider
的实例,代码如下:
[FirebaseTrackingProvider()].forEach {
TrackingRepo.shared.register(trackingProvider: $0)
}
我们通过调用TrackingRepo
的register
方法来注册FirebaseTrackingProvider
的实例,这样就能把事件发送到 Firebase 统计分析服务了。如果有需要,我们还可以同时注册多个统计分析服务的 Provider。
第二步是使用TrackingRepo
的实例来发送事件。例如,下面的代码演示了如何发送页面事件:
trackingRepo.trackScreenviews(ScreenviewsTrackingEvent(screenName: L10n.Tracking.momentsScreen, screenClass: String(describing: self)))
我们通过调用trackingRepo
的trackScreenviews()
方法来发送进入朋友圈页面的事件。
3. 为不同统计分析服务自定义事件内容
不同的统计分析服务所接收的事件内容可能不一样。这里我们以点赞按钮事件为例子看看如何为 Firebase 服务自定义事件。
首先我们看一下LikeActionTrackingEvent
的定义:
struct LikeActionTrackingEvent: ActionTrackingEventType {
let momentID: String
let userID: String
}
该事件只有两个属性,其中momentID
表示点赞的朋友圈信息的 ID,而userID
表示点赞用户的 ID。为了特定给 Firebase 统计分析服务定制事件的内容,我们定义了一个名叫FirebaseActionTrackingEvent
的空协议:
protocol FirebaseActionTrackingEvent: ActionTrackingEventType { }
然后给LikeActionTrackingEvent
提供了一个类型扩展,具体代码如下:
extension LikeActionTrackingEvent: FirebaseActionTrackingEvent {
var parameters: [String : Any] {
return [
AnalyticsParameterItemID: "moment-id-\(momentID)-user-id-\(userID)",
AnalyticsParameterItemName: "moment-like"
]
}
}
该类型扩展使得LikeActionTrackingEvent
遵循了FirebaseActionTrackingEvent
协议,并为parameters
属性提供了一个默认的实现,在实现里面使用了在 Firebase SDK 里面定义的两个常量:AnalyticsParameterItemID
和AnalyticsParameterItemName
。当我们使用了这些常量时,Firebase 统计分析后台就会把事件自动映射成选择内容的 Item ID 和名字。
那提供这样一个类型扩展到底有什么好处呢?我们再看一下FirebaseTrackingProvider
里trackAction
方法的实现。
func trackAction(_ event: TrackingEventType) {
guard let event = event as? FirebaseActionTrackingEvent else {
return
}
Analytics.logEvent(AnalyticsEventSelectContent, parameters: event.parameters)
}
在该方法里面,我们通过guard
语句检查传递进来的事件是否为FirebaseActionTrackingEvent
类型,如果不是,程序就直接退出了。如果是,就调用event.parameters
属性来获取事件的内容,这时候就会调用类型扩展里parameters
属性的默认实现,类型扩展方法能保证FirebaseTrackingProvider
只发送遵循了FirebaseActionTrackingEvent
协议的事件类型。
假如我们需要为其他统计分析服务自定义事件的内容时,该怎么做呢?例如,为 Mixpanel 自定义事件,可以通过下面的代码实现:
extension LikeActionTrackingEvent: MixpanelActionTrackingEvent {
var parameters: [String : Any] {
return [
MixPanelEventKey: "moment-id-\(momentID)-user-id-\(userID)",
MixPanelEventName: "moment-like"
]
}
}
通过类型扩展来遵循不同的事件协议,我们就可以很灵活地为各个统计分析服务发送不同内容的事件了。
最后,我们再看一下如何发送LikeActionTrackingEvent
事件,具体实现如下:
trackingRepo.trackAction(LikeActionTrackingEvent(momentID: momentID, userID: userID))
trackingRepo
会自动把事件发送到各个注册的统计分析服务中,并且根据LikeActionTrackingEvent
的类型扩展来准备不同的事件内容。
4. Firebase 统计分析报告
完成了上述的开发工作后,我们就能收集用户行为数据了,并且还可以在 Firebase 的统计分析服务上查看相关的报告。下图是 Moments App 的统计分析报告:
我们可以在 Analyics 菜单下看到各种各样的报告,如事件统计、设备类型以及用户留存率等。这些报告能协助我们更准确地做出产品决定,比如,通过 iOS 活跃版本的报告能帮我们决定 App 支持 iOS 的最低版本号,假如绝大部分用户都使用 iOS 13 以上的版本,我们就引入 SwiftUI 和 Combine 等新技术。
我建议你仔细阅读统计分析服务的相关文档,并熟悉各种统计报告以及指标,从而助力产品的增长。
总结
在这一讲,我们讲述了如何架构一个灵活的统计分析模块,有了这个模块,我们就可以很方便地支持和替换不同的统计分析服务。同时,我们还以 Firebase 为例子讲述了如何配置 Firebase 的统计分析服务。总之,我希望你能好好地利用这些分析报告和指标,进而助力产品的增长。
思考题
请参照 FirebaseTrackingProvider 来编写一个遵循 TrackingProvider 协议的 SystemLogTrackingProvider 来打印日志,在该 Provider 里通过系统提供的 os_log() 方法来打印事件。
请把你的答案写到留言区或者提交一个 PR 哦。下一讲我将介绍如何使用 Firebase 的崩溃报告服务去解决线上的 Bug,记得按时来听课。
源码地址
统计分析模块源码地址:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/Analytics
28 崩溃报告:如何借助崩溃报告解决线上的 Bug?
App 在运行过程中发生闪退会给用户带来极其恶劣的体验,因此,用户往往会把经常闪退的 App 直接删掉。同样地,对开发者来说,重现线上问题也是件困难的事,因为这些 Bug 可能与用户使用时的网络连接状态、iOS 系统版本、内存空间、是否越狱等有关。那有没有什么好办法能帮助我们解决线上的 Bug,并提升用户体验呢?
崩溃报告是一种解决线上闪退问题的有效办法。崩溃报告可以实时收集真实用户在使用 App 过程中发生闪退的信息,并将其解释成对开发者友好的报告,这可以很好地帮助我们确认和诊断线上的问题。
可以这么说,崩溃报告服务已经成为 App 不可或缺的支撑功能。在 Moments App 中,我选择了 Firebase Crashlytics 作为崩溃报告服务。与市面上其他服务相比,Firebase Crashlytics 有以下 5 个优点。
-
Crashlytics 产品有 10 年的历史,经过这快 10 年的实践检验,我们发现该产品非常稳定。
-
Crashlytics 能同时支持 iOS 和 Android 等平台,方便我们在同一个地方查看所有 App 的崩溃报告。
-
Crashlytics 完美地整合在 Firebase 里面,可以与 Firebase 其他服务一同使用,例如可以配合性能监控一起使用。
-
与 Firebase 的其他产品一样,Crashlytics 可以免费使用。
-
fastlane 支持 Crashlytics 的整合,只需要简单的配置就可以通过 CI 自动化上传 dSYM 文件。
下面我们就来看看如何在 Moments App 里面使用 Crashlytics。
配置 Crashlytics
在使用 Crashlytics 前,我们需要完成一次性的配置。
首先登录到 Firebase 网站,并通过位于左边 Crashlytics 菜单打开 Crashlytics 页面,接着点击“Enable Crashlytics”按钮来启动 Crashlytics 功能。
启动 Crashlytics 服务以后,在 Podfile 文件里添加以下的 Pod:
def thirdparty_pods
pod 'Firebase/Crashlytics', '= 7.0.0'
pod 'Firebase/Performance', '= 7.0.0'
end
其中,Firebase/Crashlytics
是用于崩溃报告服务的 Pod,而Firebase/Performance
是用于性能监控的 Pod。完成上面的配置以后,只需重新执行bundle exec pod install
命令就能完成 Crashlytics 的安装了。
最后一步是调用FirebaseApp.configure()
函数来启动崩溃报告服务。如果你已经使用了统计分析服务,那么这一步之前就做过了。
自动化上传 dSYM 文件
完成了上述的配置以后,一旦发生闪退,在 Firebase Crashlytics 页面就能看到相关的闪退信息,除此之外,你还可能会看到以下的警告页面:
该页面告诉我们“Missing required dSYMs”,中文意思就是“缺了必需的 dSYM 文件”。那什么是 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_crashlytics
Action 来帮我们简化上传 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_crashlytics
Action 时,我们需要传递三个参数:首先把 dSYM 文件的路径传递给dsym_path
参数,然后把 Firebase 的配置文件传递给gsp_path
参数,最后是把 Firebase API Token 传递给api_token
参数。在前面的《25 | 自动化构建:解决大量重复性人力工作神器》里我们已经讲述过如何获取这个 Token 了,我们是将FIRBASE_API_TOKEN
环境变量配置在 local.keys 文件里面。
接下来我们再一起看看上传 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
可以看到,upload_symbols_to_crashlytics_appstore
与upload_symbols_to_crashlytics_internal
的实现基本一样,唯一不同的地方是upload_symbols_to_crashlytics_appstore
把 GoogleService-Info-AppStore.plist 文件传递给了gsp_path
参数。
有了这些 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
步骤。
查看崩溃报告
得到了上传的 dSYM 文件以后,Crashlytics 就能自动处理 dSYM 文件,并把崩溃信息解释成对开发者友好的报告,如下图所示:
报告中最关键的信息是堆栈回溯(Trace Stack),它会把 App 闪退前所调用的方法名称、代码执行的行号都按顺序依次打印出来,这样能方便我们对照着源码来定位问题。
另外,Crashlytics 还能把收集到的设备信息显示出来,方便我们重现和诊断问题,这些信息如下图所示:
如果我们同时使用了 Firebase 的统计分析服务,那么 Crashlytics 还会给我们提供闪退前的用户行为事件,方便我们按照这些步骤来重现问题,如下图所示:
除了提供崩溃报告以外,Crashlytics 还能提供可配置的警告信息,Crashlytics 会根据崩溃率的阈值来给我们及时发送警告通知。例如,下面的配置表示当有 0.1% 的用户在最近一小时内发生闪退时就发送警告通知。
性能报告
因为我们安装了Firebase/Performance
Pod,所以 Firebase 会自动生成性能监控报告,如下图所示:
这些报告能为我们提供网络运行状态、屏幕呈现速度、App 启动速度等指标。
这里我们还可以为各个指标配置不同阈值与目标值来进一步监控 App 的性能状况。比如,下图显示了 App 启动速度指标的详细信息。
除了启动速度以外,Crashlytics 还提供了各种脱敏信息,例如操作系统的版本、设备的类型等,这些信息能帮助我们更准确地定位性能问题的瓶颈。
总结
在这一讲中,我们讲述了如何使用 Firebase Crashlytics 来收集崩溃报告,还讲解了如何使用 fastlane 来开发上传 dSYM 文件的操作,以及通过 CI 的配置来完成全自动化上传。通过与统计分析服务相结合,Crashlytics 能提供详细的崩溃信息,帮助我们快速地诊断和定位线上的 Bug,从而降低崩溃率,提升用户的使用体验。
这里我再分享一些减少闪退的有效办法。你可以根据项目的具体情况,合理配置崩溃率的阈值,并随着 App 质量的提高而不断降低崩溃率的阈值配置。然后,在发布新版本的时候采用分阶段发布的方式,例如,通过发布 1% 的用户来观察崩溃率是否提升,一旦超过一定的阈值就马上暂停发布,修复好引起崩溃的 Bug 后再重新发布新版本。总之,结合我自己的开发经验来看,通过合理配置崩溃率阈值和分阶段发布的方式,可以在很大程度上降低闪退的概率,所以,在你的开发工作中,建议你可以考虑使用。
思考题
请问你是通过什么办法来解决线上崩溃的问题呢?能分享一下你的经验吗?
请把你的答案写到留言区哦。下一讲我将介绍“如何使用远程开关来远程遥控上线 App 的产品行为”的相关内容,记得按时来听课。
源码地址
Fastfile 文件地址:https://github.com/lagoueduCol/iOS-linyongjian/blob/main/fastlane/Fastfile#L264-L281
.travis.yml 文件地址:https://github.com/lagoueduCol/iOS-linyongjian/blob/main/.travis.yml#L35
29 远程开关:如何远程遥控上线 App 的产品行为?
在前面《09 | 开关组件:如何使用功能开关,支持产品快速迭代》那一讲中,我介绍过如何实现编译时开关和本地开关。有了这两种开关,我们就可以很方便地让测试人员在 App 里面手动启动或者关闭一些功能。那有没有什么好的办法可以让产品经理远程遥控功能呢?远程开关就能完成这一任务。
通过远程开关,我们就可以在无须发布新版本的情况下开关 App 的某些功能,甚至可以为不同的用户群体提供不同的功能。 远程功能开关能帮助我们快速测试新功能,从而保证产品的快速迭代。
远程功能开关模块的架构与实现
下面我们通过 Moments App 来看看如何架构一个灵活的远程功能开关模块,并使用 Firebase 来实现一个远程功能开关。该模块主要由两部分所组成:Remote Config 模块和Toggle 模块。远程功能开关模块的架构图如下所示:
1. Remote Config 模块的架构与实现
由于 Toggle 模块依赖于 Remote Config 模块,所以我们就先看一下 Remote Config 模块的架构与实现。
Remote Config 也叫作“远程配置”,它可以帮助我们把 App 所需的配置信息存储在服务端,让所有的 App 在启动的时候读取相关的配置信息,并根据这些配置信息来调整 App 的行为。 Remote Config 应用广泛,可用于远程功能开关、 A/B 测试和强制更新等功能上。
Remote Config 的架构十分简单,由RemoteConfigKey
和RemoteConfigProvider
所组成,其中RemoteConfigKey
是一个空协议(Protocol),用于存放配置信息的唯一标识,其定义如下:
protocol RemoteConfigKey { }
为了支持 Firebase 的 Remote Config 服务,我们定义一个遵循了RemoteConfigKey
协议的枚举类型(Enum), 其具体的代码如下:
enum FirebaseRemoteConfigKey: String, RemoteConfigKey {
case isRoundedAvatar
}
因为 Firebase Remote Config 的标识都是字符串类型,所以我们把FirebaseRemoteConfigKey
的rawValue
也指定为String
类型,这样就能很方便地取出case
的值,例如,通过FirebaseRemoteConfigKey.isRoundedAvatar.rawValue
来得到“isRoundedAvatar”字符串。
有了配置信息的标识以后,我们再来看看如何在 App 里面访问 Remote Config 服务。首先,我们定义一个名叫RemoteConfigProvider
的协议,其定义如下:
protocol RemoteConfigProvider {
func setup()
func fetch()
func getString(by key: RemoteConfigKey) -> String?
func getInt(by key: RemoteConfigKey) -> Int?
func getBool(by key: RemoteConfigKey) -> Bool
}
RemoteConfigProvider
协议定义了setup()
、fetch()
等五个方法。为了使用 Firebase 的Remote Config 服务,我们定义了一个结构体FirebaseRemoteConfigProvider
来遵循该协议,该结构体实现了协议里的五个方法。
我们先来看一下setup()
和fetch()
方法的具体代码实现:
private let remoteConfig = RemoteConfig.remoteConfig()
func setup() {
remoteConfig.setDefaults(fromPlist: "FirebaseRemoteConfigDefaults")
}
func fetch() {
remoteConfig.fetchAndActivate()
}
在初始化的时候,我们调用 Firebase SDK 所提供的RemoteConfig.remoteConfig()
方法来生成一个RemoteConfig
的实例并赋值给remoteConfig
属性,然后在setup()
里调用remoteConfig.setDefaults(fromPlist:)
方法从 FirebaseRemoteConfigDefaults.plist 文件里读取配置的默认值。下图展示的就是该 plist 文件,在该文件里,我们把isRoundedAvatar
的默认值设置为 false,这样能保证 App 在无法联网的情况下也能正常运行。
在fetch()
里,我们调用了 Firebase SDK 里的fetchAndActivate()
方法来获取远程配置信息。
接着我们再来看看另外三个方法的具体实现:
func getString(by key: RemoteConfigKey) -> String? {
guard let key = key as? FirebaseRemoteConfigKey else {
return nil
}
return remoteConfig[key.rawValue].stringValue
}
func getInt(by key: RemoteConfigKey) -> Int? {
guard let key = key as? FirebaseRemoteConfigKey else {
return nil
}
return Int(truncating: remoteConfig[key.rawValue].numberValue)
}
func getBool(by key: RemoteConfigKey) -> Bool {
guard let key = key as? FirebaseRemoteConfigKey else {
return false
}
return remoteConfig[key.rawValue].boolValue
}
这三个方法都使用了RemoteConfigKey
作为标识符从remoteConfig
对象里读取相关的配置信息,然后把获取到的信息分别转换成所需的类型,例如字符串、整型或者布尔类型。
至此,我们就实现了 Remote Config 模块,假如还需要支持其他的远程配置服务,只需为RemoteConfigProvider
协议实现另外一个子类型即可,例如需要支持 Optimizely 的远程配置服务时,可以实现一个名叫OptimizelyRemoteConfigProvider
的结构体来封装访问 Optimizely 后台服务的逻辑。
2. Toggle 模块的架构与实现
有了 Remote Config 模块,实现 Toggle 模块就变得十分简单了。在前面《09 | 开关组件:如何使用功能开关,支持产品快速迭代》里面,我们讲过 Toggle 模块的架构与实现。要添加远程开关的支持,我们只需要增加两个实现类型:RemoteToggle
和FirebaseRemoteTogglesDataStore
结构体。我们先看一下RemoteToggle
的实现:
enum RemoteToggle: String, ToggleType {
case isRoundedAvatar
}
和编译时开关以及本地开关一样,RemoteToggle
也是一个遵循了ToggleType
协议的枚举类型。所有的远程开关功能的名称都罗列在case
里面,例如,isRoundedAvatar
表示是否把朋友圈页面里的头像显示为圆形。
有了功能开关的名称定义以后,我们就要为TogglesDataStoreType
提供一个远程开关的具体实现。因为我们使用了 Firebase 服务,所以就把它命名为FirebaseRemoteTogglesDataStore
,其具体实现如下:
struct FirebaseRemoteTogglesDataStore: TogglesDataStoreType {
static let shared: FirebaseRemoteTogglesDataStore = .init()
private let remoteConfigProvider: RemoteConfigProvider
private init(remoteConfigProvider: RemoteConfigProvider = FirebaseRemoteConfigProvider.shared) {
self.remoteConfigProvider = remoteConfigProvider
self.remoteConfigProvider.setup()
self.remoteConfigProvider.fetch()
}
func isToggleOn(_ toggle: ToggleType) -> Bool {
guard let toggle = toggle as? RemoteToggle, let remoteConfiKey = FirebaseRemoteConfigKey(rawValue: toggle.rawValue) else {
return false
}
return remoteConfigProvider.getBool(by: remoteConfiKey)
}
func update(toggle: ToggleType, value: Bool) { }
}
FirebaseRemoteTogglesDataStore
依赖了 Remote Config 模块。在init()
方法里面,我们通过依赖注入的方式把FirebaseRemoteConfigProvider
的实例传递进来,并调用setup()
方法来初始化 Firebase 的 Remote Config 服务,然后调用fetch()
方法来读取所有的配置信息。
因为FirebaseRemoteTogglesDataStore
遵循了TogglesDataStoreType
协议,所以必须实现isToggleOn(_:)
和update(toggle:value:)
两个方法。
isToggleOn(_:)
方法用于判断某个开关是否打开,在方法实现里,我们先判断传递进来的toggle
是否为RemoteToggle
类型,然后再判断该 Toggle 的名称是否匹配FirebaseRemoteConfigKey
里的定义。如果都符合条件,那么就可以调用remoteConfigProvider
的getBool(by:)
方法来判断开关是否打开。
update(toggle: ToggleType, value: Bool)
方法的实现非常简单,因为 App 是无法更新远程开关信息的,所以它的实现为空。
至此,我们就为 Toggle 模块添加好了 Firebase 远程开关的支持。
远程开关的使用与配置
使用远程开关仅仅需要两步,下面我们就以MomentListItemView
为例子看看如何使用远程开关来控制头像的显示风格吧。
第一步是在init()
方法里面把TogglesDataStoreType
子类型的实例通过依赖注入的方式传递进去,具体代码如下:
private let remoteTogglesDataStore: TogglesDataStoreType
init(frame: CGRect = .zero, ..., remoteTogglesDataStore: TogglesDataStoreType = FirebaseRemoteTogglesDataStore.shared) {
self.remoteTogglesDataStore = remoteTogglesDataStore
super.init(frame: frame)
}
因为 Moments App 使用了 Firebase 作为远程开关服务,所以我们就把FirebaseRemoteTogglesDataStore
的实例赋值给remoteTogglesDataStore
属性。
第二步是调用isToggleOn(_:)
方法来判断远程开关是否开启,示例代码如下:
if remoteTogglesDataStore.isToggleOn(RemoteToggle.isRoundedAvatar) {
avatarImageView.asAvatar(cornerRadius: 10)
}
我们把isRoundedAvatar
作为标识符来调用isToggleOn(_:)
方法,如果该方法返回true
,就把avatarImageView
的圆角设置为 10 pt。因为avatarImageView
的高度和宽度都为 20 pt,所以当圆角设置为 10 pt 时就会显示为圆形。
就这样,我们就能在 App 里使用名为isRoundedAvatar
的远程开关了。假如要使用其他的远程开关,只需要在RemoteToggle
和FirebaseRemoteConfigKey
两个枚举类型里添加新的case
,并在 FirebaseRemoteConfigDefaults.plist 文件设置默认值即可。
但是,产品经理怎样才能在 Firebase 服务端配置远程开关呢?下面我们一起看一下这个配置的步骤吧。
我们可以在 Firebase 网站上点击 Engage -> Remote Config 菜单来打开 Remote Config 配置页面,然后点击“Add parameter”来添加一个名叫“isRoundedAvatar”的配置,如下图所示:
当添加或修改完配置后,一定要记住点击下图的“Publish changes”按钮来发布更新。
现在我们就能很方便地在 Firebase 网站上修改“isRoundedAvatar”配置的值来控制头像的显示风格了。
除了简单地启动或者关闭远程开关以外,Firebase 还可以帮我们根据用户的特征进行条件配置,例如,我们可以让所有使用中文的用户启动圆形头像风格,而让其他语言的用户保留原有风格。
下面我们就来看看如何在 Firebase 网站上进行条件配置。
我们可以点击修改按钮的图标来打开修改弹框,然后点击“Add value for condition”按钮来添加条件。如下图所示,我们添加了一个名叫“Chinese users”的条件,该条件会判断用户是否使用中文作为他们设备的默认语言。
然后我们就可以为符合该条件的用户配置不同的值,例如在下图中,符合“Chinese users”条件的用户在读取“isRoundedAvatar”配置时都会得到true
。
下面是 Moments App 运行在不同语言设备上的效果图,你可以对比一下。
总结
在这一讲中,我们主要讲解了如何架构一个灵活的远程开关模块,该模块可以使用不同的后台服务来支持远程开关。接着我们以 Firebase 作为例子讲述了如何使用 Remote Config 来实现一个头像风格的远程开关,并且演示了如何根据用户的特征来为远程开关配置不同的值。
有了远程开关,产品经理就能很方便地遥控 App 的行为,并能快速地尝试新功能。但需要注意的是:不能滥用远程开关,并且最好能经常回顾上线的远程开关,把测试完毕的开关及时删除掉,否则会导致 App 里面的开关越来越多,使得程序的逻辑变得十分复杂且难以维护,再加上每个远程开关都需要从网络读取相关的配置信息,太多的开关还会影响到用户的使用体验。
思考题
在 FirebaseRemoteTogglesDataStore 里面,为什么没有直接使用 Firebase SDK 来读取 Remote Config 呢?另外,把读取 Remote Config 的逻辑封装在 FirebaseRemoteConfigProvider 里有什么好处呢?
可以把你的思考与答案写到留言区哦。下一讲我将介绍“如何使用 A/B 测试协助产品抉择”的相关内容,记得按时来听课哦。
源码地址
RemoteConfig 源码地址:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/RemoteConfig
远程开关源码地址:https://github.com/lagoueduCol/iOS-linyongjian/blob/main/Moments/Moments/Foundations/Toggles/FirebaseRemoteTogglesDataStore.swift