Appearance
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
绑定到一起。
swift
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
为例子来看看具体代码是怎样实现的。
swift
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
的代码。
swift
enum InternalMenuItemType: String {
case description
case featureToggle
case actionTrigger
}
因为我们在为InternalMenuViewController
的tableView
注册 Cell 的时候使用了这个枚举作为ReuseIdentifier
,因此把这个枚举的原始值(Raw value)定义为String
类型。下面是注册 Cell 时的代码。
swift
$tableView.register(InternalMenuDescriptionCell.self, forCellReuseIdentifier: InternalMenuItemType.description.rawValue)
为了提高代码的可扩展性,我们在架构和开发 Moments App 时都遵守面向协议编程(Protocol Oriented Programming)的原则。落实到这个地方,我们为三个 ViewModel 抽象出一个共同的协议InternalMenuItemViewModel
,其代码如下:
swift
protocol InternalMenuItemViewModel {
var type: InternalMenuItemType { get }
var title: String { get }
func select()
}
InternalMenuItemViewModel
定义了两个属性分别用于表示 Cell 类型以及显示的标题,同时也定义了一个名叫select()
方法来处理 Cell 的点击事件。我们在InternalMenuViewController
里通过 RxDataSources 把tableView
和InternalMenuItemViewModel
绑定起来,使得InternalMenuItemViewModel
可以处理 Cell 的点击事件。代码如下:
swift
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()
方法 。代码如下:
swift
extension InternalMenuItemViewModel {
func select() { }
}
下面一起看看不同类型 Cell 所对应的 ViewModel 实现方法。
InternalMenuDescriptionItemViewModel
InternalMenuDescriptionItemViewModel
用于显示描述类型的 Cell,其功能非常简单,就是显示一句描述信息,例如 App 的版本号。其代码实现也十分容易,首先它需要实现来自InternalMenuItemViewModel
的type
属性并返回.description
,然后实现title
属性来存储描述信息的字符串。 其具体代码如下:
swift
struct InternalMenuDescriptionItemViewModel: InternalMenuItemViewModel {
let type: InternalMenuItemType = .description
let title: String
}
InternalMenuFeatureToggleItemViewModel
InternalMenuFeatureToggleItemViewModel
用于存放本地功能开关的配置数据,因此它引用了上一讲提到过的InternalTogglesDataStore
来存储和读取本地开关的信息。
除了实现type
和title
属性以外,它提供了两个关键的接口供外部使用:
命名为
isOn
的计算属性(Computed property),供外部读取开关的状态;toggle(isOn: Bool)
方法,给外部更新开关的状态。
具体代码如下:
swift
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 是一个类。代码如下:
swift
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()
方法。下面是它的两个子类的实现代码。
swift
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
明确指明其类型信息,代码如下。
swift
public protocol SectionModelType {
associatedtype Item
var items: [Item] { get }
init(original: Self, items: [Item])
}
因为InternalMenuSection
遵循了SectionModelType
协议,所以需要明确指明Item
的类型为InternalMenuItemViewModel
。InternalMenuSection
还实现了两个init
方法来进行初始化。具体代码如下。
swift
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 的数据,代码如下。
swift
protocol InternalMenuViewModelType {
var title: String { get }
var sections: Observable<[InternalMenuSection]> { get }
}
InternalMenuViewModel
作为一个遵循InternalMenuViewModelType
协议的结构体,它要实现title
和sections
属性。其中,title
只是返回包含标题的字符串即可。而sections
则需要使用 RxSwift 的Observable
来返回一个数组,这个数组包含了多个 Session ViewModel。
我们会在响应式编程一讲中详细讲述Observable
。在此你可以把它理解为一个能返回数组的数据流。下面是具体的代码实现。
swift
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
协议,所以它们能够保持统一的接口,方便我们快速扩展新功能。比如,我们要为实时聊天功能添加一个新的本地功能开关时,只需要下面一行代码就行了。
swift
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