[toc]
前言
本文来自拉勾网课程整理
不知道在工作当中,你有没有为了测试和验证开发中的功能,特意为测试和产品经理打包一个特殊版本的 App
?或者当多个团队并行开发的时候,为了测试,每个团队都单独打包出不同版本的App
?还有当你想添加某些供内部使用的功能(如清理 Cache
),但又不想让 App Store
的用户使用,你是不是又专门打包了一个特殊版本的 App?
每次遇到这些情况,你是不是觉得特麻烦?
其实,这些都可以通过一个内部隐藏功能菜单来解决
。在这一章结合我们的 Moments App
来和你介绍下,如何开发了一个隐藏功能菜单,快速实现功能测试和验证。
![](https://img.haomeiwen.com/i2280900/1cfbf0db5cb8a056.jpg)
下面是隐藏菜单模块使用到的所有源代码文件。
![](https://img.haomeiwen.com/i2280900/03c50dc12728fc2c.jpg)
我把这些模块中使用到的类型分成两大类:
- 用于呈现的
View
,主要分为ViewController + Tableview
以及TableViewCell
两层; - 用于的存储配置数据的
ViewModel
,它分为用于TableView
的ViewModel
,用于TableView Section
的ViewModel
以及用于TableView Cell
的ViewModel
。
![](https://img.haomeiwen.com/i2280900/ca7512c68fb33ce6.jpg)
View
下面是View
部分的所有类型的关系图。
![](https://img.haomeiwen.com/i2280900/bfcfbf73dee6d923.jpg)
隐藏菜单的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
由于位于上层的类型会引用到下层的类型,为了更好地理解它们的依赖关系,我准备从下往上为你介绍各层类型的实现。
![](https://img.haomeiwen.com/i2280900/fa253d7dc1c446bc.jpg)
前面提到过,我把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()
。当用户点击闪退AppCell
的时候,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
协议。
![](https://img.haomeiwen.com/i2280900/8fc2b194d0a2fdff.jpg)
因为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
吧。
![](https://img.haomeiwen.com/i2280900/379ed90b199472b9.jpg)
用于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)
![](https://img.haomeiwen.com/i2280900/a31742e020845693.jpg)
总结
有了这个功能,我们的测试人员和产品经理可以使用这些功能来加速功能的测试与验证。在实现过程,我们把` UI` 和配置数据部分进行分离,而且使用了面向协议的编程方式,让这个功能变得灵活且易于可扩展。在实际工作当中,你也可以使用这个模式来快速开发出各种配置页面。
![](https://img.haomeiwen.com/i2280900/9b49544f26e5f75b.jpg)
思考题:
在当前的实现中还可以进一步的优化,请尝试把
InternalMenuDesignKitDemoItemViewModel
和InternalMenuCrashAppItemViewModel
重构成结构体(struct)
,做完记住提交一个PR
哦。
源码地址:
隐藏菜单功能的文件地址:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/InternalMenu
网友评论