08 设计组件:DeignKit 组件桥接设计与开发规范
在上一模块“配置与规范”中,我主要介绍了如何统一项目的配置,以及如何制定统一开发和设计规范。
接下来我们将进入基础组件设计模块,我会为你介绍一些在 iOS 开发过程中,工程化实践需要用的组件,比如设计组件、路由组件。除此之外,我还会聊聊在开发中如何支持多语言、动态字体和深色模式等辅助功能,让你的 App 既有国际范,获取更多用户,还能提升用户体验,获得更多好评。
这一讲,我们就先来聊聊公共组件库,以及如何封装基础设计组件。
封装公共功能组件库
随着产品不断发展,我们会发现,越来越多的公共功能可以封装成组件库,从而被各个模块甚至多个 App 共同使用,比如字体、调色板、间距和头像可以封装成 UI 设计组件库,登录会话和权限管理可以封装成登录与鉴权组件库。
通过利用这些公共功能组件库,不仅能节省大量开发时间,不需要我们再为每个模块重复实现类似的功能;还能减少编译时间,因为如果没有独立的组件库,一点代码的改动都会导致整个 App 重新编译与链接。
那么,怎样才能创建和使用公共功能组件库呢?下面我们以一个设计组件库 DesignKit 为例子介绍下具体怎么做。
创建内部公共功能组件库
公共功能组件库根据使用范围可以分为三大类:内部库、私有库和开源库。
-
内部库是指该库和主项目共享一个 Repo ,它可以共享到主项目的所有模块中。
-
私有库是指该库使用独立的私有 Repo ,它可以共享到公司多个 App 中。
-
开源库是指该库发布到 GitHub 等开源社区提供给其他开发者使用。
这三类库的创建和使用方式都是一致的。在实际操作中,我们一般先创建内部库,如果今后有必要,可以再升级为私有库乃至开源库。下面咱们一起看看怎样创建内部库。
为了方便管理各个内部公共功能组件库,首先我们新建一个叫作Frameworks 的文件夹来保存所有的内部库。这个文件夹和主项目文件夹(在我们例子中是 Moments)以及 Workplace 文档(Moments.xcworkspace)平衡。例如下面的文件结构:
Frameworks Moments Pods Moments.xcworkspace
然后我们通过 CocoaPods 创建和管理这个内部库。
怎么做呢?有两种办法可以完成这项工作,一种是使用pod lib create [pod name]
命令。比如在这个案例当中,我们可以在 Frameworks 文件夹下执行bundle exec pod lib create DesignKit
命令,然后输入邮箱、语言和平台等信息,让 CocoaPods 创建一个 DesignKit.podspec 以及例子项目等一堆文件。具体如下:
DesignKit Example README.md
DesignKit.podspec LICENSE _Pods.xcodeproj
DesignKit.podspec 是 DesignKit 库的 Pod 描述文件,用于描述该 Pod 库的一个特定版本信息。它存放在 CocoaPods 的中心 Repo 供使用者查找和使用。
随着这个 Pod 库的迭代,CocoaPods 的中心 Repo 会为每个特定的 Pod 版本存放一个对应的 podspec 文件。每个 podspec 文件都包括 Pod 对应 Repo 的 URL、源码存放的位置、所支持的系统平台及其系统最低版本号、Swift 语言版本,以及 Pod 的名字、版本号和描述等信息。
DesignKit 组件库的 podspec 文件你可以在拉勾教育的仓库中找到。下面是该 podspec 文件的一些重要配置:
s.name = 'DesignKit'
s.version = '1.0.0'
s.ios.deployment_target = ‘14.0’
s.swift_versions = ‘5.3’
s.source_files = ‘src//*'
s.resources = 'assets//*’
name
是该组件的名字,version
是组件的版本号,当我们更新组件的时候同时需要使用 Semantic Versioning(语义化版本号)更新该版本号。
ios.deployment_target
为该库所支持的平台和所支持平台的最低版本号。swift_versions
是支持 Swift 语言的版本号。source_files
是该库的源代码所在的文件夹,在我们例子中是 src。resources
是该库资源文件所在的文件夹。
另外一种是手工创建 DesignKit.podspec 文件。我偏向于这一种,因为手工创建出来的项目更简练。
比如在这里,我们只需要在 Frameworks 新建一个叫作 DesignKit 的文件夹,然后在它下面建立 src 和 assets 这两个文件夹,以及 LICENSE 和 DesignKit.podspec 这两个文件即可。
如下所示:
DesignKit.podspec LICENSE assets src
以后所有源代码文件都存放在 src 文件夹下面,而图片、Xib 和 Storyboard 等资源文件存放在 assets 文件夹下。
LICENSE 是许可证文件,如果是开源库,我们必须严格选择一个许可证,这样才能方便其他开发者使用我们的库。
检测内部公共功能组件库
为了保证组件库的使用者能顺利安装和使用我们的库,当我们配置好 DesignKit.podspec 文件后,需要执行bundle exec pod spec lint
命令来检测该 podspec 文件是否正确。如果我们维护的是一个开源库,这一步尤为重要。因为它会影响到使用者的第一印象,因此我们在发布该 Pod 之前需要把每个错误或者警告都修复好。
不过需要注意的是, CocoaPods 对内部库的检测存在一个 Bug, 会显示下面的警告以及错误信息:
WARN | Missing primary key for source attribute
ERROR | unknown: Encountered an unknown error (Unsupported download strategy `{:path=>"."}`.) during validation
由于我们创建的是内部库,所以可以忽略这个警告和错误,只要没有其他错误信息就可以了。
使用内部公共功能组件库
使用内部公共功能组件库非常简单,只要在主项目的 Podfile 里面使用:path
来指定该内部库的路径即可。
pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false
当执行bundle exec pod install
命令以后,CocoaPods 会在 Pods 项目下建立一个Development Pods文件夹来存放所有内部库的相关文件。
有了 CocoaPods,我们新建、管理和使用公共组件库就会变得非常简单。下面我们介绍下如何开发设计组件 DesignKit。
DesignKit 设计组件
DesignKit 是一个设计组件,用于封装与 UI 相关的公共组件。为了方便维护,每次新增一个组件,我们最好都建立一个独立的文件夹,例如把 Spacing.swift 放在新建的 Spacing 文件夹中。
下面以几乎每个 App 都会使用到的三个组件:间距(Spacing)、头像(Avatar)和点赞按钮(Favorite Button)为例子,介绍下如何封装基础设计组件。
间距
为了呈现信息分组并体现信息的主次关系,所有 App 的所有页面都会使用到间距来添加留白效果。
间距看起来这么简单,为什么我们还需要为其独立封装为一个公共组件呢?主要原因有这么几条。
-
可以为整个 App 提供一致的体验,因为我们统一定义了所有间距,各个功能模块的 UI 呈现都保持一致。
-
可以减低设计师和开发者的沟通成本,不会再为某些像素值的多与少而争论不休。设计师只使用预先定义的间距,而开发者也只使用在代码中定义好的间距就行了。
-
可以减低设计师的工作量,很多 UI 界面可以只提供一个设计稿来同时支持 iOS、Android 以及移动 Web。因为设计师只提供预先定义的间距名,而不是 hardcoded (硬编码)的像素值。不同设备上像素值有可能不一样,但间距名却能保持一致。
-
在支持响应式设计的时候,这些间距定义可以根据设备的宽度而自动调整。这远比硬编码的像素值灵活很多,例如在 iPhone 中 twoExtraSmall 是 4 points,而在 iPad 中是 6 points。
别看间距公共组件有那么多优点,但实现起来并不难,一个struct就搞定了,简直是一本万利的投入。
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
}
有了上述的定义以后,使用这些间距变得很简单。请看:
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)
}
我们可以先 import (引入) DesignKit 库,然后通过Spacing
结构体直接访问预定义的间距,例如Spacing.twoExtraSmall
。
头像组件
iOS 开发者都知道,头像组件应用广泛,例如在房产 App 中显示中介的头像,在我们例子 Moments App 中显示自己和好友头像,在短视频 App 中显示视频博主头像等。
也许你会问,头像那么简单,为什么需要独立封装为一个组件?原因主要是方便以后改变其 UI 的呈现方式,例如从圆角方形改成圆形,添加边界线(border),添加阴影效果(shadow)等。有了独立的组件以后,我们只需要修改一个地方就能把这个 App 的所有头像一次性地修改呈现效果。
下面是头像组件的实现方式:
public extension UIImageView {
func asAvatar(cornerRadius: CGFloat = 4) {
clipsToBounds = true
layer.cornerRadius = cornerRadius
}
}
我们为 UIKit 所提供的UIImageView
实现了一个扩展方法asAvatar(cornerRadius:)
,该方法接收cornerRadius
作为参数来配置圆角的角度,默认值是4
。
使用也是非常简单,只有创建一个UIImageView
的实例,然后调用asAvatar(cornerRadius:)
方法即可。
private let userAvatarImageView: UIImageView = configure(.init()) {
$0.asAvatar(cornerRadius: 4)
}
这是人像组件的显示效果,可以在内部菜单查看。
点赞按钮
可以说,每个具有社交属性的 App 都会用到点赞功能,所以在开发当中,点赞按钮也是必不可少的功能组件。
那么,点赞按钮该如何封装呢?和人像组件十分类似,我们可以通过扩展UIButton
来实现。示例代码如下:
public extension UIButton {
func asStarFavoriteButton(pointSize: CGFloat = 18, weight: UIImage.SymbolWeight = .semibold, scale: UIImage.SymbolScale = .default, fillColor: UIColor = UIColor(hex: 0xf1c40f)) {
let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: weight, scale: scale)
let starImage = UIImage(systemName: "star", withConfiguration: symbolConfiguration)
setImage(starImage, for: .normal)
let starFillImage = UIImage(systemName: "star.fill", withConfiguration: symbolConfiguration)
setImage(starFillImage, for: .selected)
tintColor = fillColor
addTarget(self, action: #selector(touchUpInside), for: .touchUpInside)
}
}
private extension UIButton {
@objc
private func touchUpInside(sender: UIButton) {
isSelected = !isSelected
}
}
其核心逻辑把当前 UIButton 对象的普通 (.normal
) 状态和选中 (.selected
) 状态设置不同的图标。比如在这里我就把星星按钮的普通状态设置成了名叫 “Star” 的图标,并把它的选中状态设置成了名叫 “tar.fill"” 的图标。
注意,这些图标来自苹果公司的 SF Symbols 不需要额外安装,iOS 14 系统本身就自带了。而且它们的使用也非常灵活,支持字号、字重、填充色等配置。
使用点赞按钮组件也非常简单,只需要建立一个UIButton
的实例,然后调用asStarFavoriteButton
方法就可以了。
private let favoriteButton: UIButton = configure(.init()) {
$0.asStarFavoriteButton()
}
点赞按钮的运行效果,也可以在内部菜单查看。
以上我们以间距、头像、点赞按钮为例介绍了如何使用 DesignKit 封装与 UI 相关的公共组件。以我多年的开发经验来说,在封装 UI 组件的时候,可以遵循下面几个原则。
-
尽量使用扩展方法而不是子类来扩展组件,这样做可以使其他开发者在使用这些组件时,仅需要调用扩展方法,而不必使用特定的类。
-
尽量使用代码而不要使用 Xib 或者 Storyboard,因为有些 App 完全不使用 Interface Builder。
-
如果可以,要为组件加上
@IBDesignable
和@IBInspectable
支持,这样能使得开发者在使用 Interface Builder 的时候预览我们的组件。 -
尽量只使用 UIkit 而不要依赖任何第三方库,否则我们可能会引入一个不可控的依赖库。
总结
前面我介绍了如何封装公共功能组件库,以及以怎样封装基础设计组件,希望对你有所帮助。合理使用功能组件可以让你的开发事半功倍。
不过,在封装组件的时候,我还需要提醒你注意这么几点。
首先,为了减低组件之间的耦合性,提高组件的健壮性,组件的设计需要符合单一功能原则 。也就是说,一个组件只做一件事情,一个组件库只做一类相关的事情。每个组件库都要相对独立且功能单一。
比如,我们可以分别封装网络库、UI 库、蓝牙处理库等底层库,但不能把所有库合并在一个单独的库里面,这样可以方便上层应用按需使用这些依赖库。例如,广告 SDK 可以依赖于网络库、UI 库,但并不依赖蓝牙处理库。这样做一方面可以减少循环依赖的可能性,另一方面可以加快编译和链接的速度,方便使用。
其次,每次发布新增和更新组件的时候,都需要严格按照 Semantic Versioning 来更新版本号,这样有效防止因为版本的问题而引入 Bug。
最后,组件的开发并不是一蹴而就,很多时候可以根据业务需求把公共模块一点点地移入公共组件库中,一步步地完善组件库的功能。不要为了开发组件而开发组件,很多时候当我们充分理解了使用者的需求后,才能为组件定义完善的接口和完整的功能。
思考题:
上面我们讲述了如何使用 CocoaPods 来封装内部组件,请问怎样把内部组件升级成为私有组件和开源组件呢?
可以把回答写到下面的留言区哦,我们下一讲将介绍如何使用功能开关支持产品快速迭代。
源码地址:
DesignKit 源代码:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Frameworks/DesignKit
09 开关组件:如何使用功能开关,支持产品快速迭代
代码管理规范一讲我提到过,开发功能的时候要新建功能分支。在实际工作当中,有一种功能分支我把它叫作长命功能分支(Long lived feature branch),因为有些大功能需要我们花几周甚至几个月来开发,相对应地它的功能分支也会非常庞大。
当整个功能开发完毕后,我们需要把它合并到主分支里面,因为里面代码实在太多了,不可避免地就会出现许多合并冲突。哪怕勉强修正并编译通过,App 里面也很可能隐藏一些不容易发现的 Bug。
怎样解决这种难题呢?
通常的办法是我们会把一个庞大的功能分拆成多个小任务,每个任务都建一个独立的功能分支,当一个任务完成后马上合并到主分支里面。
如上图所示,为了完成一个庞大的功能,如实时通讯功能,我们分拆成多个小的功能分支,比如显示对话的子功能分支branch-1
,显示表情符号的子功能分支branch-2
等等。注意,我在图里面使用数字作为分支名字只是为了演示同一个大功能使用多个子分支,在现实情况下要根据每个小任务的内容进行命名。
但这样拆分成多个小的功能分支会导致什么问题呢?会出现我们每次把小任务合并到主分支的时候,不小心把未完成的功能发布给用户。例如我们在发布V1.2
版本的 App 时就包含了branch-1
分支上的代码,可是整个功能还没有开发完毕,我们并不愿意把这部分代码发布给 App Store 的用户。
那有没有什么办法既能及时合并代码,又能保证主分支可以随时发布呢?有,答案就是使用功能开关组件。
功能开关的好处
功能开关是软件开发中一种十分实用且功能强大的技术,它允许我们在不改变代码的情况下调整软件的行为。有了它,我们在快速开发的同时,也能放心定期发布新版本。
具体来说,当我们开发每一个大功能时,如果先定义一个功能开关,然后在整个功能开发完毕后再移除它,那么在移除之前,我们提交的任何版本就能避免因为疏忽大意而把未完成的功能发布到终端用户手上。
如上图所示,当我们开发一个大功能时,先从主分支签出一个功能分支add-toggle
来添加一个功能开关。 然后我们就可以把该功能拆分成多个子功能分支来进行开发,并不断地合并到主分支里面。在整个功能开发完毕以后,我们可以从主分支签出一个分支remove-toggle
来把功能开关删除掉。这个功能只会在 V1.4
版本里面才会发布给终端用户。在此之前用户完全不知道这个功能的存在。
使用功能开关是保证主分支可以随时进行发布的有效手段。可以说,一个能随时发布的主分支是衡量一个开发环境成熟与否的标准,为什么这样说呢?有两大原因:
-
它是多团队平行开发的基础,当其中一个团队完成了一个功能以后,就可以在不与任何其他团队沟通的情况下马上进行发布;
-
它是自动化发布的基础,有了随时可以发布的主分支,我们只需要更新版本号就可以让 CI 在任何时候打包并发布到 App Store,无须任何人为参与。
既然功能开关那么强大,那怎么实现呢?接下来我们就结合 Moments App 一起来看看。
功能开关组件的实现
根据不同的使用场景,我们为Moments App 开发了三类功能开关组件:编译时开关、本地开关和远程开关。
-
编译时开关:让编译器通过检查编译条件来启动或者关闭一些功能。
-
本地开关:让用户在 App 里面手动启动或者关闭一些功能。
-
远程开关:让产品经理远程遥控 App 来启动或者关闭一些功能。
下面我们一起来看看怎样实现这些功能开关组件吧。首先我们定义了一个名字叫作 ToggleType
的协议(Protocol),然后分别定义了三个枚举类型(Enum)来代表三类开关。
有了这些功能开关的定义以后,接着我们定义这些开关的 DataStore。首先建立了一个名叫 TogglesDataStoreType
的协议,它只定义了两个方法,其中isToggleOn(_ toggle: ToggleType) -> Bool
用于读取某个开关的值,而 update(toggle: ToggleType, value: Bool)
用于更新某个开关的值。
然后我们为每一类开关定义一个实现的结构体(Struct)。因为远程开关我会在后面的 29 讲详细介绍,所以在这一讲我主要为你介绍下编译时开关和本地开发怎样实现的。
编译时开关组件
下面是编译时开关 BuildTargetTogglesDataStore
的实现代码。
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) { }
}
因为 BuildTargetTogglesDataStore
遵循了 TogglesDataStoreType
,我在这里实现了 isToggleOn
和 update
两个方法。由于我们不可能在运行时更新编译时的编译条件,因此 update
方法的实现体为空。
而在 isToggleOn
方法里面,我们会检查传递进来的 ToggleType
的值是否和属性 buildTarget
的值相等,如果相等就返回 true
,如果不相等就返回 false
。
那 buildTarget
的值是怎样来的呢?我们可以看看 init
方法。
在 init 方法里面,我们有三条判断编译条件的语句,当编译条件包含了 INTERNAL
时,就会把 buildTarget
赋值为 .internal
。那 INTERNAL
从哪里来的呢?
我在如何搭建多环境支持那一讲提到过怎样通过 xcconfig
文件来配置多个 Build Configuration
。该编译条件 INTERNAL
来自 InternalProject.xcconfig
文件。如下所示,我们把 INTERNAL
赋值给 SWIFT_ACTIVE_COMPILATION_CONDITIONS
。
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) INTERNAL
在编译的时候,编译器会从SWIFT_ACTIVE_COMPILATION_CONDITIONS
读取编译条件的值,当发现该编译条件已经定义了,就会编译 #if #endif
代码块内的代码。
回到上图 init 方法的例子,当我们选择了 Moments-Internal Scheme 时,编译器就会选择名字叫作 Internal 的 Build Configuration。这个 Configuration 读取 InternalProject.xcconfig 后把SWIFT_ACTIVE_COMPILATION_CONDITIONS
赋值为INTERNAL
。因此,在编译的时候,上面的代码只有第 25 行会编译,第 21 行和第 29 行都会被忽略掉。
通过编译时开关,我们就能让不同环境版本的 App 激活或者隐藏不同的功能。下面是如何导航到隐藏功能菜单页面的代码。它的大致逻辑是,我们通过重写 UIWindow 的 motionEnded 方法来捕捉手机震动的事件。
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)
}
}
}
}
具体来说,当用户在摇动手机的时候,代码会通过BuildTargetTogglesDataStore
的isToggleOn
方法来检查当前的 App 是否为开发环境或者测试环境版本,如果“是”就会使用AppRouter
到导航到隐藏功能菜单页面。如果“不是”(如生产环境 App Store版本),就不进行导航。这样能保证从 App Store 上下载 App 的用户,没办法使用隐藏菜单的功能。
本地开关组件
如果说编译时开关能够方便你为不同环境的 App 激活或者隐藏不同的功能,那么本地开关则可以让内部测试人员和产品经理随时测试和验证功能,从而保证产品快速的迭代。
下面一起看看本地开关的实现代码。
enum InternalToggle: String, ToggleType {
case isLikeButtonForMomentEnabled
case isSwiftUIEnabled
}
struct InternalTogglesDataStore: TogglesDataStoreType {
private let userDefaults: UserDefaults
private init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
self.userDefaults.register(defaults: [
InternalToggle.isLikeButtonForMomentEnabled.rawValue: false,
InternalToggle.isSwiftUIEnabled.rawValue: false
])
}
static let shared: InternalTogglesDataStore = .init(userDefaults: .standard)
func isToggleOn(_ toggle: ToggleType) -> Bool {
guard let toggle = toggle as? InternalToggle else {
return false
}
return userDefaults.bool(forKey: toggle.rawValue)
}
func update(toggle: ToggleType, value: Bool) {
guard let toggle = toggle as? InternalToggle else {
return
}
userDefaults.set(value, forKey: toggle.rawValue)
}
}
当每次新增一个本地开关的时候,我们都在InternalToggle
枚举里面添加一个case
。当前的 Moments App 有两个本地开关,分别用于“点赞功能”和“使用 SwiftUI”。
因为InternalTogglesDataStore
也遵循了TogglesDataStoreType
协议,所以也需要实现isToggleOn
和update
两个方法。它们都使用了UserDefaults
来读写配置信息,这样能保证用户重启 App 以后也能获取之前所选择的开关配置。
这里我给你分享一个管理本地开关的技巧。
在init
方法里面,我通过UserDefaults
的register
方法为每个开关赋予默认值。绝大多数情况下,这个默认值都为false
,表示该功能还没有发布。
那什么时候这个值会成为 true
呢?假如我们完成了一个功能,而且产品经理验证过可以发布上线,那么我就可以把该开关的默认值设为true
。但上线以后发现该功能引起严重的崩溃,我们可以马上把该值修改为false
并立刻发布新版本。
还有一点需要注意的是,当功能上线并运行正常的情况下,要及时清理相关的功能开关,因为开关太多会增加代码逻辑的复杂度。
下面一起看看代码中使用本地开关的例子吧。
if InternalTogglesDataStore.shared.isToggleOn(InternalToggle.isSwiftUIEnabled) {
window?.rootViewController = UIHostingController(rootView: SwiftUIMomentsListView().environmentObject(UserDataStoreObservableObject()))
} else {
window?.rootViewController = MomentsListViewController()
}
我们通过InternalTogglesDataStore
来检查.isSwiftUIEnabled
开关是否启动,如果“是”就启动 SwiftUI 的界面,否则就启动 UIKit 的界面。下面是演示的效果。
我们可以在内部隐藏菜单里面启动或者关闭 SwiftUIEnable开关。App 在启动的时候会根据该开关来选择启动 SwiftUI 或者 UIKit 的界面。
总结
这一讲我主要介绍了如何使用功能开关来解决长命功能分支的问题,并详细介绍了如何开发编译时开关组件和本地开发组件。有了这些功能开关组件以后,既保证我们能快速开发功能,又能保证发布的版本不会出错,让整个团队在安全的环境下快速迭代。
思考题:
请问你的团队是怎样让测试人员检查未上线功能的?有什么好的经验或者教训给大家分享一下?
可以把回答写到下面的留言区哦,我们下一讲将介绍如何开发内部隐藏菜单,通过隐藏功能来分离生产和研发环境。
源码地址:
功能开关文件地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/Toggles
10 支撑组件:如何实现隐藏菜单,快速测试与验证?
不知道在工作当中,你有没有为了测试和验证开发中的功能,特意为测试和产品经理打包一个特殊版本的 App?或者当多个团队并行开发的时候,为了测试,每个团队都单独打包出不同版本的 App?还有当你想添加某些供内部使用的功能(如清理 Cache),但又不想让 App Store 的用户使用,你是不是又专门打包了一个特殊版本的 App?
每次遇到这些情况,你是不是觉得特麻烦?
其实,这些都可以通过一个内部隐藏功能菜单来解决。在这一讲我就结合我们的 Moments App 来和你介绍下,如何开发了一个隐藏功能菜单,快速实现功能测试和验证。
Moments App 的隐藏菜单
下面是隐藏菜单模块使用到的所有源代码文件。
我把这些模块中使用到的类型分成两大类:
-
用于呈现的 View,主要分为 ViewController + Tableview 以及 TableViewCell 两层;
-
用于存储配置数据的 ViewModel,它分为用于 TableView 的 ViewModel,用于 TableView Section 的 ViewModel 以及用于 TableView Cell 的 ViewModel。
下面是所有类型的分类总揽图,你可以简单看一下,我会在后面进行一一介绍。
View
下面是 View 部分的所有类型的关系图。
隐藏菜单的 UI 使用了 UIKit 的UITableView
来实现,其包含了四大部分:通用信息、DesignKit 范例、功能开关和工具箱,每一部分都是一个 TableView Section。
为了提高可重用性,以便于快速开发新的隐藏功能,我们把UITableView
嵌入到UIViewController
的子类InternalMenuViewController
里面。然后通过 RxDataSources 把tableView
和viewModel
绑定到一起。
let dataSource = RxTableViewSectionedReloadDataSource<InternalMenuSection>(
configureCell: { _, tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: item.type.rawValue, for: indexPath)
if let cell = cell as? InternalMenuCellType {
cell.update(with: item)
}
return cell
}, titleForHeaderInSection: { dataSource, section in
return dataSource.sectionModels[section].title
}, titleForFooterInSection: { dataSource, section in
return dataSource.sectionModels[section].footer
})
viewModel.sections
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
你可以看到,RxDataSources 帮我们把 UIKit 里面恼人的 DataSource 和 Delegate 通过封包封装起来。当生成 Cell 的时候,统一调用InternalMenuCellType
协议的update(with item: InternalMenuItemViewModel)
方法来更新 Cell 的 UI。因此所有的 Cell 都必须遵循InternalMenuCellType
协议。
根据 Cell 的不同作用,我们把它分成三类:
-
用于显示描述信息的
InternalMenuDescriptionCell
-
用于响应点击事件的
InternalMenuActionTriggerCell
-
用于功能开关的
InternalMenuFeatureToggleCell
它们都必须实现InternalMenuCellType
协议里面的update(with item: InternalMenuItemViewModel)
方法。下面以InternalMenuDescriptionCell
为例子来看看具体代码是怎样实现的。
class InternalMenuDescriptionCell: UITableViewCell, InternalMenuCellType {
func update(with item: InternalMenuItemViewModel) {
guard let item = item as? InternalMenuDescriptionItemViewModel else {
return
}
selectionStyle = .none
textLabel?.text = item.title
}
}
在update
的方法里,我们通过guard
语句检查并把item
的类型从InternalMenuItemViewModel
向下转型(downcast)为InternalMenuDescriptionItemViewModel
。因为只有在类型转换成功的时候,才能更新当前 Cell 的 UI。InternalMenuActionTriggerCell
和InternalMenuFeatureToggleCell
的实现方法也和InternalMenuDescriptionCell
一样。
到此为止, View 部分的实现以及完成了。你可能会问InternalMenuItemViewModel
和InternalMenuDescriptionItemViewModel
那些类型是哪里来的?我们一起来看看 ViewModel 部分吧。
ViewModel
ViewModel 的作用是为 View 准备需要呈现的数据,因此 ViewModel 的类型层级关系也与 View 类型层级关系一一对应起来,分成三大类。
-
用于准备 TableView 数据的
InternalMenuViewModel
-
用于准备 TableView Section 数据的
InternalMenuSection
-
由于准备 TableView Cell 数据的
InternalMenuItemViewModel
由于位于上层的类型会引用到下层的类型,为了更好地理解它们的依赖关系,我准备从下往上为你介绍各层类型的实现。
用于 TableView Cell 的 ViewModel
前面提到过,我把 Cell 分成了三类,与之对应的 ViewModel 也分成三类。我定义了一个名叫InternalMenuItemType
的枚举类型(enum)来存放这些分类信息,假如以后要在隐藏菜单里开发新功能的 Cell,我们可以在该类型里面增加一个case
。下面是当前InternalMenuItemType
的代码。
enum InternalMenuItemType: String {
case description
case featureToggle
case actionTrigger
}
因为我们在为InternalMenuViewController
的tableView
注册 Cell 的时候使用了这个枚举作为ReuseIdentifier
,因此把这个枚举的原始值(Raw value)定义为String
类型。下面是注册 Cell 时的代码。
$tableView.register(InternalMenuDescriptionCell.self, forCellReuseIdentifier: InternalMenuItemType.description.rawValue)
为了提高代码的可扩展性,我们在架构和开发 Moments App 时都遵守面向协议编程(Protocol Oriented Programming)的原则。落实到这个地方,我们为三个 ViewModel 抽象出一个共同的协议InternalMenuItemViewModel
,其代码如下:
protocol InternalMenuItemViewModel {
var type: InternalMenuItemType { get }
var title: String { get }
func select()
}
InternalMenuItemViewModel
定义了两个属性分别用于表示 Cell 类型以及显示的标题,同时也定义了一个名叫select()
方法来处理 Cell 的点击事件。我们在InternalMenuViewController
里通过 RxDataSources 把tableView
和InternalMenuItemViewModel
绑定起来,使得InternalMenuItemViewModel
可以处理 Cell 的点击事件。代码如下:
tableView.rx
.modelSelected(InternalMenuItemViewModel.self)
.subscribe(onNext: { item in
item.select()
})
.disposed(by: disposeBag)
当用户点击 TableView 上某个 Cell 的时候,就会调用对应的 ViewModel 的select()
方法。 但并不是所有的 Cell 都需要响应点击的事件,例如用于描述 App 版本号的 Cell,就不需要处理点击事件。
为了简化开发的工作量,我们为InternalMenuItemViewModel
定义了一个名叫select()
的协议扩展方法,并且为该协议提供了一个默认的实现,即当遵循InternalMenuItemViewModel
协议的类型未实现select()
方法时,程序就会执行协议扩展所定义的select()
方法 。代码如下:
extension InternalMenuItemViewModel {
func select() { }
}
下面一起看看不同类型 Cell 所对应的 ViewModel 实现方法。
InternalMenuDescriptionItemViewModel
InternalMenuDescriptionItemViewModel
用于显示描述类型的 Cell,其功能非常简单,就是显示一句描述信息,例如 App 的版本号。其代码实现也十分容易,首先它需要实现来自InternalMenuItemViewModel
的type
属性并返回.description
,然后实现title
属性来存储描述信息的字符串。 其具体代码如下:
struct InternalMenuDescriptionItemViewModel: InternalMenuItemViewModel {
let type: InternalMenuItemType = .description
let title: String
}
InternalMenuFeatureToggleItemViewModel
InternalMenuFeatureToggleItemViewModel
用于存放本地功能开关的配置数据,因此它引用了上一讲提到过的InternalTogglesDataStore
来存储和读取本地开关的信息。
除了实现type
和title
属性以外,它提供了两个关键的接口供外部使用:
-
命名为
isOn
的计算属性(Computed property),供外部读取开关的状态; -
toggle(isOn: Bool)
方法,给外部更新开关的状态。
具体代码如下:
struct InternalMenuFeatureToggleItemViewModel: InternalMenuItemViewModel {
private let toggle: ToggleType
private let togglesDataStore: TogglesDataStoreType
init(title: String, toggle: ToggleType, togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared) {
self.title = title
self.toggle = toggle
self.togglesDataStore = togglesDataStore
}
let type: InternalMenuItemType = .featureToggle
let title: String
var isOn: Bool {
return togglesDataStore.isToggleOn(toggle)
}
func toggle(isOn: Bool) {
togglesDataStore.update(toggle: toggle, value: isOn)
}
}
InternalMenuActionTriggerItemViewModel
我们为响应点击事件的 Cell 都封装在InternalMenuActionTriggerItemViewModel
里面,该 ViewModel 是一个类。代码如下:
class InternalMenuActionTriggerItemViewModel: InternalMenuItemViewModel {
var type: InternalMenuItemType { .actionTrigger }
var title: String { fatalError(L10n.Development.fatalErrorSubclassToImplement) }
func select() { fatalError(L10n.Development.fatalErrorSubclassToImplement) }
}
InternalMenuActionTriggerItemViewModel
遵循了InternalMenuItemViewModel
协议,因此也需要实现type
属性,并返回.actionTrigger
,同时我还实现了title
属性和select()
方法,它们都直接抛出fatalError
错误。这是为什么呢?
因为我们想把InternalMenuActionTriggerItemViewModel
定义为一个抽象类,然后把title
属性和select()
方法都定义为抽象属性和抽象方法。可是 Swift 并不支持抽象类,为了模拟概念上的抽象类,我们定义了一个普通的类,然后在title
属性和select()
方法里面抛出fatalError
错误。
这样做有两个作用,第一是能防止调用者直接构造出InternalMenuActionTriggerItemViewModel
的实例。第二是强迫其子类重写title
属性和select()
方法。下面是它的两个子类的实现代码。
final class InternalMenuCrashAppItemViewModel: InternalMenuActionTriggerItemViewModel {
override var title: String {
return L10n.InternalMenu.crashApp
}
override func select() {
fatalError()
}
}
final class InternalMenuDesignKitDemoItemViewModel: InternalMenuActionTriggerItemViewModel {
private let router: AppRouting
private let routingSourceProvider: RoutingSourceProvider
init(router: AppRouting, routingSourceProvider: @escaping RoutingSourceProvider) {
self.router = router
self.routingSourceProvider = routingSourceProvider
}
override var title: String {
return L10n.InternalMenu.designKitDemo
}
override func select() {
router.route(to: URL(string: "\(UniversalLinks.baseURL)DesignKit"), from: routingSourceProvider(), using: .show)
}
}
当我们为InternalMenuActionTriggerItemViewModel
定义子类的时候,为了让子类不能被其他子类所继承,而且提高编译速度,我们把子类InternalMenuCrashAppItemViewModel
和InternalMenuDesignKitDemoItemViewModel
都定义成final class
。
这两个子类都重写了title
属性和select()
方法。下面分别看看它们的具体实现。
InternalMenuCrashAppItemViewModel
的作用是把 App 给闪退了,因此在其select()
方法里面调用了fatalError()
。当用户点击闪退 App Cell 的时候,App 会立刻崩溃并退出。
而InternalMenuDesignKitDemoItemViewModel
是用于打开 DesignKit 的范例页面。我们在其select()
方法里面调用了router.route(to:from:using)
进行导航。当用户点击 DesignKit 范例 Cell 的时候,App 会导航到 DesignKit 的范例页面,方便设计师和产品经理查看公共设计组件。
以上是如何开发用于显示UITableViewCell
的 ViewModel 。下面一起看看 TableView Section 所对应的 ViewModel。
用于 TableView Section 的 ViewModel
为了准备 TableView Section 的数据,我建立一个名叫InternalMenuSection
的结构体(Struct)。这个结构体遵循了自于 RxDataSources 的SectionModelType
协议。
因为SectionModelType
使用了associatedtype
来定义Item
的类型,所有遵循该协议的类型都必须为Item
明确指明其类型信息,代码如下。
public protocol SectionModelType {
associatedtype Item
var items: [Item] { get }
init(original: Self, items: [Item])
}
因为InternalMenuSection
遵循了SectionModelType
协议,所以需要明确指明Item
的类型为InternalMenuItemViewModel
。InternalMenuSection
还实现了两个init
方法来进行初始化。具体代码如下。
struct InternalMenuSection: SectionModelType {
let title: String
let items: [InternalMenuItemViewModel]
let footer: String?
init(title: String, items: [InternalMenuItemViewModel], footer: String? = nil) {
self.title = title
self.items = items
self.footer = footer
}
init(original: InternalMenuSection, items: [InternalMenuItemViewModel]) {
self.init(title: original.title, items: items, footer: original.footer)
}
}
有了用于UITableViewCell
和 TableView Section 的 ViewModel 以后,现在就剩下最后一个了,一起看看如何实现一个用于UITableView
的 ViewModel 吧。
用于 TableView 的 ViewModel
用于UITableView
的 ViewModel 也是遵循面向协议编程的原则。首先,我们定义了一个名叫InternalMenuViewModelType
的协议。该协议只有两个属性title
和sections
。其中,title
用于显示 ViewController 的标题,sections
用于显示 TableView 的数据,代码如下。
protocol InternalMenuViewModelType {
var title: String { get }
var sections: Observable<[InternalMenuSection]> { get }
}
InternalMenuViewModel
作为一个遵循InternalMenuViewModelType
协议的结构体,它要实现title
和sections
属性。其中,title
只是返回包含标题的字符串即可。而sections
则需要使用 RxSwift 的Observable
来返回一个数组,这个数组包含了多个 Session ViewModel。
我们会在响应式编程一讲中详细讲述Observable
。在此你可以把它理解为一个能返回数组的数据流。下面是具体的代码实现。
struct InternalMenuViewModel: InternalMenuViewModelType {
let title = L10n.InternalMenu.area51
let sections: Observable<[InternalMenuSection]>
init(router: AppRouting, routingSourceProvider: @escaping RoutingSourceProvider) {
let appVersion = "\(L10n.InternalMenu.version) \((Bundle.main.object(forInfoDictionaryKey: L10n.InternalMenu.cfBundleVersion) as? String) ?? "1.0")"
let infoSection = InternalMenuSection(
title: L10n.InternalMenu.generalInfo,
items: [InternalMenuDescriptionItemViewModel(title: appVersion)]
)
let designKitSection = InternalMenuSection(
title: L10n.InternalMenu.designKitDemo,
items: [InternalMenuDesignKitDemoItemViewModel(router: router, routingSourceProvider: routingSourceProvider)])
let featureTogglesSection = InternalMenuSection(
title: L10n.InternalMenu.featureToggles,
items: [
InternalMenuFeatureToggleItemViewModel(title: L10n.InternalMenu.likeButtonForMomentEnabled, toggle: InternalToggle.isLikeButtonForMomentEnabled),
InternalMenuFeatureToggleItemViewModel(title: L10n.InternalMenu.swiftUIEnabled, toggle: InternalToggle.isSwiftUIEnabled)
])
let toolsSection = InternalMenuSection(
title: L10n.InternalMenu.tools,
items: [InternalMenuCrashAppItemViewModel()]
)
sections = .just([
infoSection,
designKitSection,
featureTogglesSection,
toolsSection
])
}
}
从代码可以看到,InternalMenuViewModel
的主要任务是把各个 Cell 的 ViewModel 进行初始化,然后放进各组 Section 的 ViewModel 里面,最后把各组 Section 的 ViewModel 放到items
属性里面。
因为所有用于UITableViewCell
的 ViewModel 都遵循了InternalMenuItemViewModel
协议,所以它们能够保持统一的接口,方便我们快速扩展新功能。比如,我们要为实时聊天功能添加一个新的本地功能开关时,只需要下面一行代码就行了。
InternalMenuFeatureToggleItemViewModel(title: L10n.InternalMenu.instantMessagingEnabled, toggle: InternalToggle.isInstantMessagingEnabled)
运行效果如下。
总结
在这一讲中,我向你介绍了如何实现一个隐藏菜单功能,有了这个功能,我们的测试人员和产品经理可以使用这些功能来加速功能的测试与验证。在实现过程,我们把 UI 和配置数据部分进行分离,而且使用了面向协议的编程方式,让这个功能变得灵活且易于可扩展。在实际工作当中,你也可以使用这个模式来快速开发出各种配置页面。
思考题:
在当前的实现中还可以进一步的优化,请尝试把
InternalMenuDesignKitDemoItemViewModel
和InternalMenuCrashAppItemViewModel
重构成结构体(struct),做完记住提交一个 PR 哦。
如果你在做这个任务时有任何问题,可以写到下面的留言区哦,我会不定期回复。我们下一讲将介绍如何开发通用的路由组件。
源码地址:
隐藏菜单功能的文件地址:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/InternalMenu