美文网首页
10 | 支撑组件:如何实现隐藏菜单,快速测试与验证?

10 | 支撑组件:如何实现隐藏菜单,快速测试与验证?

作者: 清风烈酒2157 | 来源:发表于2021-05-06 17:02 被阅读0次

[toc]

前言

本文来自拉勾网课程整理

不知道在工作当中,你有没有为了测试和验证开发中的功能,特意为测试和产品经理打包一个特殊版本的 App?或者当多个团队并行开发的时候,为了测试,每个团队都单独打包出不同版本的App?还有当你想添加某些供内部使用的功能(如清理 Cache),但又不想让 App Store 的用户使用,你是不是又专门打包了一个特殊版本的 App?

每次遇到这些情况,你是不是觉得特麻烦?

其实,这些都可以通过一个内部隐藏功能菜单来解决。在这一章结合我们的 Moments App 来和你介绍下,如何开发了一个隐藏功能菜单,快速实现功能测试和验证。

ba23408815eadbb8c31ea1360240d411

下面是隐藏菜单模块使用到的所有源代码文件。


e0d988e772238c3705697952e2561316

我把这些模块中使用到的类型分成两大类:

  • 用于呈现的 View,主要分为 ViewController + Tableview 以及 TableViewCell 两层;
  • 用于的存储配置数据的 ViewModel,它分为用于 TableViewViewModel,用于 TableView SectionViewModel 以及用于 TableView CellViewModel
4806053f7bdb85a9ca7383676d0849eb

View

下面是View部分的所有类型的关系图。

89f12b6469a3f89578b15187df1d27b7

隐藏菜单的UI使用了 UIKitUITableView来实现,其包含了四大部分:通用信息、DesignKit 范例、功能开关和工具箱,每一部分都是一个 TableView Section

为了提高可重用性,以便于快速开发新的隐藏功能,我们把UITableView嵌入到UIViewController的子类InternalMenuViewController里面。然后通过 RxDataSourcestableViewviewModel绑定到一起。

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 里面恼人的 DataSourceDelegate 通过封包封装起来。当生成Cell的时候,统一调用InternalMenuCellType协议的update(with item: InternalMenuItemViewModel)方法来更新 CellUI。因此所有的 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。因为只有在类型转换成功的时候,才能更新当前CellUIInternalMenuActionTriggerCellInternalMenuFeatureToggleCell的实现方法也和InternalMenuDescriptionCell一样。
到此为止,View 部分的实现以及完成了。你可能会问InternalMenuItemViewModelInternalMenuDescriptionItemViewModel那些类型是哪里来的?我们一起来看看 ViewModel 部分吧。

ViewModel

ViewModel 的作用是为 View 准备需要呈现的数据,因此 ViewModel 的类型层级关系也与 View 类型层级关系一一对应起来,分成三大类。

  • 用于准备 TableView 数据的InternalMenuViewModel
  • 用于准备 TableView Section 数据的InternalMenuSection
  • 由于准备 TableView Cell 数据的InternalMenuItemViewModel

由于位于上层的类型会引用到下层的类型,为了更好地理解它们的依赖关系,我准备从下往上为你介绍各层类型的实现。

c882d9814a7cfbf11b258614660e6896

前面提到过,我把Cell 分成了三类,与之对应的 ViewModel 也分成三类。我定义了一个名叫InternalMenuItemType的枚举类型(enum)来存放这些分类信息,假如以后要在隐藏菜单里开发新功能的 Cell,我们可以在该类型里面增加一个case。下面是当前InternalMenuItemType的代码。

enum InternalMenuItemType: String {
    case description
    case featureToggle
    case actionTrigger
}


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

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


InternalMenuFeatureToggleItemViewModel

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

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

  • 命名为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定义子类的时候,为了让子类不能被其他子类所继承,而且提高编译速度,我们把子类InternalMenuCrashAppItemViewModelInternalMenuDesignKitDemoItemViewModel都定义成final class

这两个子类都重写了title属性和select()方法。下面分别看看它们的具体实现。

InternalMenuCrashAppItemViewModel的作用是把 App 给闪退了,因此在其select()方法里面调用了fatalError()。当用户点击闪退AppCell的时候,App 会立刻崩溃并退出。

InternalMenuDesignKitDemoItemViewModel是用于打开 DesignKit 的范例页面。我们在其select()方法里面调用了router.route(to:from:using)进行导航。当用户点击DesignKit 范例Cell 的时候,App 会导航到DesignKit 的范例页面,方便设计师和产品经理查看公共设计组件。

以上是如何开发用于显示UITableViewCellViewModel 。下面一起看看 TableView Section所对应的 ViewModel

用于 TableView Section 的 ViewModel

为了准备 TableView Section 的数据,我建立一个名叫InternalMenuSection的结构体(Struct)。这个结构体遵循了自于RxDataSourcesSectionModelType协议。

9a07a8866eb4e6fbbb765c6e1038ce89

因为SectionModelType使用了associatedtype来定义Item的类型,所有遵循该协议的类型都必须为Item明确指明其类型信息,代码如下。

public protocol SectionModelType {
    associatedtype Item
    var items: [Item] { get }
    init(original: Self, items: [Item])
}


因为InternalMenuSection遵循了SectionModelType协议,所以需要明确指明Item的类型为InternalMenuItemViewModelInternalMenuSection还实现了两个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)
    }
}


有了用于UITableViewCellTableView SectionViewModel 以后,现在就剩下最后一个了,一起看看如何实现一个用于UITableViewViewModel 吧。

acfa3b25416f79ff7430369d2d97a8ba

用于UITableViewViewModel 也是遵循面向协议编程的原则。首先,我们定义了一个名叫InternalMenuViewModelType的协议。该协议只有两个属性titlesections。其中,title用于显示 ViewController 的标题,sections用于显示 TableView 的数据,代码如下。

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


InternalMenuViewModel作为一个遵循InternalMenuViewModelType协议的结构体,它要实现titlesections属性。其中,title只是返回包含标题的字符串即可。而sections则需要使用 RxSwiftObservable来返回一个数组,这个数组包含了多个 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的主要任务是把各个CellViewModel进行初始化,然后放进各组 SectionViewModel 里面,最后把各组 SectionViewModel 放到items属性里面。

因为所有用于UITableViewCellViewModel 都遵循了InternalMenuItemViewModel协议,所以它们能够保持统一的接口,方便我们快速扩展新功能。比如,我们要为实时聊天功能添加一个新的本地功能开关时,只需要下面一行代码就行了。

InternalMenuFeatureToggleItemViewModel(title: L10n.InternalMenu.instantMessagingEnabled, toggle: InternalToggle.isInstantMessagingEnabled)


c4661704657bfde9e37f85edf2f00f9b

总结

  有了这个功能,我们的测试人员和产品经理可以使用这些功能来加速功能的测试与验证。在实现过程,我们把` UI` 和配置数据部分进行分离,而且使用了面向协议的编程方式,让这个功能变得灵活且易于可扩展。在实际工作当中,你也可以使用这个模式来快速开发出各种配置页面。
d6d498473c113e0af90c2c2bdc7e1026

思考题:

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

源码地址:

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

相关文章

  • 10 | 支撑组件:如何实现隐藏菜单,快速测试与验证?

    [toc] 前言 本文来自拉勾网课程整理 不知道在工作当中,你有没有为了测试和验证开发中的功能,特意为测试和产品经...

  • element 导航菜单折叠动画,文字不隐藏

    问题 使用element导航菜单做侧边栏菜单,用递归组件实现路由菜单渲染,遇到两个问题 折叠后文字不隐藏 折叠动画...

  • Vue动态组件

    1、什么是动态组件? 动态组件指的是动态切换组件的显示与隐藏 2、如何实现动态组件渲染? vue提供了一个内置的<...

  • RN学习链接

    1.[React Native 中如何实现根据state控制组件的显示与隐藏](https://segmentfa...

  • vue-动态组件

    1. 什么是动态组件 动态组件指的是动态切换组件的显示与隐藏。 2. 如何实现动态组件渲染 vue 提供了一个内置...

  • vue的动态组件 keep-alive

    1. 什么是动态组件 动态组件指的是 动态切换组件的显示与隐藏 2. 如何实现动态组件渲染 vue提供了一个内置的...

  • 2020.6.23

    今天主要讲了移动组件和其下拉菜单,包括复制等。还有如何显示和隐藏约束,记住装配约束和阵列组件,以及镜像组件。总得...

  • UI组件模块设计思路

    模块设计思路 功能职责细分 UI组件 组件类型 如何创建组件 菜单获取 菜单缓存管理 菜单路由跳转 组件流式布局 ...

  • macOS SwiftUI 封装组件之路径组件实现目录式菜单选择

    实战需求 macOS SwiftUI 封装组件之路径组件实现目录式菜单选择NSPathControl 本文价值与收...

  • uni表单组件封装

    展示效果 组件介绍 组件说明 此组件为一个输入框,利用它可以快速实现表单验证,输入内容 基本使用 Props 参数...

网友评论

      本文标题:10 | 支撑组件:如何实现隐藏菜单,快速测试与验证?

      本文链接:https://www.haomeiwen.com/subject/chpddltx.html