在前面《09 | 开关组件:如何使用功能开关,支持产品快速迭代》那一讲中,我介绍过如何实现编译时开关和本地开关。有了这两种开关,我们就可以很方便地让测试人员在 App 里面手动启动或者关闭一些功能。那有没有什么好的办法可以让产品经理远程遥控功能呢?远程开关就能完成这一任务。
通过远程开关,我们就可以在无须发布新版本的情况下开关 App 的某些功能,甚至可以为不同的用户群体提供不同的功能。 远程功能开关能帮助我们快速测试新功能,从而保证产品的快速迭代。
下面我们通过 Moments App 来看看如何架构一个灵活的远程功能开关模块,并使用 Firebase 来实现一个远程功能开关。该模块主要由两部分所组成:Remote Config 模块和Toggle 模块。远程功能开关模块的架构图如下所示:
由于 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 后台服务的逻辑。
有了 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
1.这种开关本地plist存的往往是false, 如果远程请求失败,是不是意味着永远只能用本地的false了?2.如果是一个很重要的开关设置在app 启动的时候,这个时候难道要等待远程数据返回吗?
1.你的理解是对的,但不可能一直都连接不上网络哦哦。2.这个开关会在第二次才生效哦,所以很重要的功能不应该放远程开关,而是事先测试好才发布。
老师好:关于配置准备:如何搭建多环境支持,为 App 开发作准备 的问题我的需求是:我的需求就是一个target多个scheme对应多个config,根据不通过的config输出不同的app(功能大小差异而已)我用xcconfig来配置工程,在a.xcconfig中PROVISIONING_PROFILE_SPECIFIER=a.mobileprovisionPRODUCT_BUNDLE_ID = com.test.a在b.xcconfig中PROVISIONING_PROFILE_SPECIFIER=b.mobileprovisionPRODUCT_BUNDLE_ID = com.test.b我当前切换到b版本archive后mobileprovision还是用的a.mobileprovision,这是需要咋设置?我现在版本是a版本它的PRODUCT_BUNDLE_ID = com.test.a, 切换到b版本后PRODUCT_BUNDLE_ID = com.test.b,archive后bundid还是PRODUCT_BUNDLE_ID = com.test.a 没有变? 我必须手动来改这个bundid,打包才会正确,我下载的你demo发现也有这个问题,你可以自己试试看?
哦哦,我的 App 是一个 Target 对应一个 Scheme。一个 target 一个 bundle id。这样就可以保证打包全自动了,可以看看后面的 《第 25 讲| 自动化构建:解决大量重复性人力工作神器 》和 《第 26 讲| 持续集成:如何实现无需人手的快速交付?》
在网站上更新配置文件,代码加载本地plist文件是怎么读到新配置的呢,不太明白。请老师解答
哦,plist 文件里面是默认值,从网站更新的值是 Firebase 服务启动的时候自动读取的并自动缓存起来,它不会更新 plist,等于 App 重新安装,或者清除缓存以后又重新读 plist 的默认值了。