Skip to content

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 把tableViewviewModel绑定到一起。

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。InternalMenuActionTriggerCellInternalMenuFeatureToggleCell的实现方法也和InternalMenuDescriptionCell一样。

到此为止, View 部分的实现以及完成了。你可能会问InternalMenuItemViewModelInternalMenuDescriptionItemViewModel那些类型是哪里来的?我们一起来看看 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
}

因为我们在为InternalMenuViewControllertableView注册 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 把tableViewInternalMenuItemViewModel绑定起来,使得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 的版本号。其代码实现也十分容易,首先它需要实现来自InternalMenuItemViewModeltype属性并返回.description,然后实现title属性来存储描述信息的字符串。 其具体代码如下:

swift
struct InternalMenuDescriptionItemViewModel: InternalMenuItemViewModel {
    let type: InternalMenuItemType = .description
    let title: String
}
InternalMenuFeatureToggleItemViewModel

InternalMenuFeatureToggleItemViewModel用于存放本地功能开关的配置数据,因此它引用了上一讲提到过的InternalTogglesDataStore来存储和读取本地开关的信息。

除了实现typetitle属性以外,它提供了两个关键的接口供外部使用:

  1. 命名为isOn的计算属性(Computed property),供外部读取开关的状态;

  2. 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定义子类的时候,为了让子类不能被其他子类所继承,而且提高编译速度,我们把子类InternalMenuCrashAppItemViewModelInternalMenuDesignKitDemoItemViewModel都定义成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的类型为InternalMenuItemViewModelInternalMenuSection还实现了两个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的协议。该协议只有两个属性titlesections。其中,title用于显示 ViewController 的标题,sections用于显示 TableView 的数据,代码如下。

swift
protocol InternalMenuViewModelType {
    var title: String { get }
    var sections: Observable<[InternalMenuSection]> { get }
}

InternalMenuViewModel作为一个遵循InternalMenuViewModelType协议的结构体,它要实现titlesections属性。其中,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 和配置数据部分进行分离,而且使用了面向协议的编程方式,让这个功能变得灵活且易于可扩展。在实际工作当中,你也可以使用这个模式来快速开发出各种配置页面。

思考题:

在当前的实现中还可以进一步的优化,请尝试把InternalMenuDesignKitDemoItemViewModelInternalMenuCrashAppItemViewModel重构成结构体(struct),做完记住提交一个 PR 哦。

如果你在做这个任务时有任何问题,可以写到下面的留言区哦,我会不定期回复。我们下一讲将介绍如何开发通用的路由组件。

源码地址:

隐藏菜单功能的文件地址:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/InternalMenu