驴妈妈客户端频道页模块化设计思路及实践

7,622 阅读12分钟

在此先感谢 文烧饼 同学纠正文中一处描述错误的地方.

要脸, 但赞不要停.

零、目录

全文字数: 3,718 | 预计阅读: 14分钟

点击展开目录
  • 一、引言
  • 二、模块定义
  • 三、模块化设计原则
    • 3.1 面向接口
    • 3.2 数据驱动
    • 3.3 模块隔离
  • 四、模块化框架设计
    • 4.1 数据源
      • 4.1.1 数据源协议
      • 4.1.2 模块组件管理
      • 4.1.3 数据流向
    • 4.2 模块组件
      • 4.2.1 组件协议
      • 4.2.2 模块组件数据模型
    • 4.3 频道代理对象协议
    • 4.4 对象通信
    • 4.5 交互图
    • 4.6 架构一览
  • 五、小结

一、引言

为了满足运营同学动态配置频道页的内容排版, 以及产品同学一次开发, 各频道复用的需求, 要开发一个框架来满足以下两点:

  • 内容灵活排版: 某个频道页展示的内容及其顺序, 甚至说一个新的频道页, 皆可由运营同学在cms后台直接配置
  • 模块全局复用: 一个承载内容的 模块 开发完后, 可在全部频道页配置使用

页面模块化的好处:

  1. 方便运营同学在线上cms后台直接创建新的界面或动态调整界面(导航栏、页头脚、内容元素等). 缩短内容上线周期

  2. 在我们不同的业务代码组件化后, 是相互隔离的. 不同的业务线开发好的业务组件难以复用(数据模型、命名、方法定义等都不统一). 而且并非所有业务组件都能封装成通用型基础组件下沉到基础组件库中. 开发这个框架, 就可以同时创建一个统一规范的业务组件库

二、模块定义

上文提及的 内容 , 就是我们各频道页看到的 模块. 不同的 模块 具有其独特的产品功能与运营目的.

以驴妈妈首页频道为例, 如下图:

每个框所圈区域为一个独立模块. 比如:

  • banner模块(产品推荐、活动推广、广告投放等)
  • 频道入口、主题列表模块(用户分流导向)
  • 旅行头条模块(热门游记推荐)
  • ...

此外, 每个模块可以包含单个或多个不同的模块组件:

三、模块化设计原则

除了考虑SOLID(六大原则)外, 框架设计还会围绕以下三点.

3.1 面向接口

通过定义 接口(即协议) 抽象和规范框架所关心的类或事. 框架与模块间低耦合.

举个例子, 对于框架来说, 它并不关心配置数据是什么结构或如何获取, 它仅关心的是有多少个模块、每个模块在容器中所占大小以及位置等数据.

可为此定义一个数据源协议, 来规范充当框架数据源对象所必须遵循的行为. 至于数据源对象的具体类型是什么不重要, 只要遵循协议即可充当框架中的某个角色.

接口 就好比一份 合同, 拟定好的 合同 就不能轻易修改, 如果贸然修改原有的条款, 那势必波及到所有遵循 合同 的人. 所以, 合同 制定阶段尤为重要, 不能好高骛远也不能鼠目寸光, 定好了大家就按照 合同 来.

当然, 面向接口与面向对象并不冲突, 反而是相辅相成, 此处不展开讨论.

3.2 数据驱动

数据决定并驱动内容的展示与响应.

  • 数据决定展示内容, 即数据与内容一一对应:

    框架根据数据源提供的相关数据, 决定每个模块该创建的组件类型, 模块组件的展示大小及布局位置等.

  • 数据驱动内容变化. 关注点为数据, 而非事件.

    举个例子, 对于框架中模块发生的任意事件, 其结果也就两种:

    1. 事件发生后, 数据有变化
    2. 事件发生后, 数据无变化

    也就说框架不会去管具体发生什么事件, 只"盯着"它所关心的数据有没有变

    另外, 事件驱动中一个事件往往对应一个响应操作, 是1对1的关系. 而数据驱动可以是1对N的关系, 可能是多个事件修改同个数据.

3.3 模块隔离

模块间相互隔离, 模块独立自治, 其相关事务自行处理.

模块可单独开发, 注册到配置中. 模块内可自行使用MVX、VIPER等结构型设计模式(Structual Design Pattern).

此外, 模块联合开发中, 框架与模块也应该适当隔离. 遵循 依赖倒置原则 . 模块(高层)依赖也不应该直接依赖框架(低层)进行开发, 框架仅知道我们抽象出来模块接口, 模块也仅知道框架接口, 双方都遵循接口进行实现.

通俗来说就是框架的功能开发与模块的开发是两条平行线, 除非修改接口, 否则双方修改实现都不会影响到另一方.

目前这块实现是传统型架构(4.6架构图), 即模块的开发是直接依赖了框架的实现(具体的基类), 框架没有抽象出暴露给上层的接口. 在遵循 依赖倒置原则 后, 模块(高层)在访问低层(框架)时, 就只能接触到遵循框架接口的某个对象(UIViewController<XxxProtocol> *), 而非具体某个XXXClass类.

还是取舍的问题, 选择性的开闭. 若完全遵循 依赖倒置原则, 那高层也没法直接依赖低层实现进行继承了, 包括哪些不允许修改, 哪些要使用公共实现, 哪些留给上层去拓展等等.

四、模块化框架设计

以iOS平台举例, 阐述对整个框架的具体设计. 抛开Android和iOS平台系统编码的风格习惯和具体实现上存在的不同, 整体思想大同小异.

4.1 数据源

一个频道页由若干个模块组成, 一个模块包含1个或多个不同的组件. 框架根据数据源提供的信息, 创建和安置模块组件.

4.1.1 数据源协议

  1. 模块数据源协议: 主要向框架提供某个模块包含的组件信息、相关的布局信息、以及组件填充数据的内容等等

    typedef NSObject<LVTSectionDataSource> LVTSectionData;
    
    @protocol LVTSectionDataSource <NSObject>
    
    - (LVTemplateClass)headerClass;
    - (LVTemplateClass)cellClassAtIndex:(NSUInteger)index;
    - ...
    
    - (BOOL)hidden;
    - (NSUInteger)numOfItems;
    - (UIEdgeInsets)sectionInset;
    - (CGFloat)itemSpace;
    - (CGFloat)lineSpace;
    - (CGSize)itemSizeAtIndex:(NSUInteger)index withContainerSize:(CGSize)size;
    - ...
    
    - (nullable LVTItemModel *)itemModelAtIndex:(NSUInteger)index;
    
    - (void)requestSectionCustomData;
    
    @end
    
  2. 频道页数据源协议: 主要向框架提供整个频道拥有的模块总数, 以及各模块的局部数据源

    @protocol LVTPageDataSource <NSObject>
     
    - (NSUInteger)numberOfSections;
    - (LVTSectionData *)sectionDataAt:(NSUInteger)section;
    
    - (void)fetchPageDataWithCompletedBlock:(void (^)(NSError _Nullable *error))completedBlk;
       
    @end
    

4.1.2 模块组件管理

对于模块内的任意组件, 都有对应一个标识ID. 我们通过一个配置文件来维护标识与组件的对应关系. 联合开发时, 每开发好一个新的组件, 就只用修改配置文件.

配置的JSON结构大致如下:

// 部分举例
{
  "header": {
    "header1": "LVTXXXHeader", // value为具体类名
    "header2": "LVTXXXHeader",
    ...
  },
  "cell": {
    "cell1": "LVTXXXCell",
    ...
  },
  ...
}

通过ID我们可获得一个具体的类名, 再使用反射获得类对象以供框架创建组件实例.

在iOS上我们通过一个ClassMapper来专门维护对应关系, 如下图.

上图类名仅为更好的表达Mapper的职责, 实际ClassMapper返回的类对象会使用泛型来进行解耦, ClassMapper中也不会引入任何组件的头文件.

4.1.3 数据流向

从原始数据到呈现到屏幕上的每个模块组件, 数据流向如下图所示:

上图各元素代表:
LVTPageDataSource为遵循 频道数据源协议 的对象
LVTSectionData为遵循 模块数据源协议 的对象
ClassMapper为管理对应关系的对象
LVTCellXXX、LVTHeaderXXX为组件等

4.2 模块组件

组件是模块化框架中复用的基础元素.

4.2.1 组件协议

模块组件分为可复用与不可复用两类, 分别对应以下协议:

  1. 复用组件协议: 提供组件用于复用队列的复用Id、用于布局的元素大小等

    typedef Class<LVTReuseItemProtocol> LVTemplateClass;
    typedef UICollectionViewCell<LVTReuseItemProtocol> LVTemplateCell;
    typedef UICollectionReusableView<LVTReuseItemProtocol> LVTemplateReuseView;
    typedef WKWebView<LVTReuseItemProtocol> LVTemplateWebView;
    
    @protocol LVTReuseItemProtocol <NSObject>
    
    + (NSString *)tIdentifier;
    + (CGSize)itemSizeWithModel:(LVTItemModel *)model andContainerSize:(CGSize)size;
    
    - (void)configItemWithModel:(LVTItemModel *)model;
    - (void)setEventCenter:(id<LVTEventCenterProtocol>)center;
    - (void)setCacheUtil:(LVTCacheUtil *)util;
    
    - (void)itemPrepareForReuse;
    
    @end
    
  2. 不可复用的悬浮组件协议: 提供视图高度, 悬浮定位信息等

     typedef Class<LVTFloatViewProtocol> LVTFloatViewClass;
    
     @protocol LVTFloatViewProtocol <NSObject>
    
     + (CGFloat)topInSection;
     + (CGFloat)viewHeight;
    
     - (void)configItemWithModel:(LVTItemModel *)model;
     - ...
    
     @end
    

数据填充等公共方法可抽象到另一个协议中, 再进行继承

4.2.2 模块组件数据模型

用于填充模块组件的数据模型类型不一, 框架也不与具体模型产生瓜葛. 通过协议规范数据模型得有的属性即可.

数据模型协议:

typedef NSObject<LVTItemModelProtocol> LVTItemModel;

@protocol LVTItemModelProtocol <NSObject>

@property (nonatomic, assign) BOOL isFolded;
@property (nonatomic, assign) CGSize itemSize;
@property (nonatomic, assign) CGSize foldedItemSize;
...

@end

我们在前边协议中看到的LVTItemModel即代表了遵循该协议的数据模型

4.3 频道代理对象协议

以上我们说的那些模块在框架中的位置都是可调整的. 对于频道中位置固定的内容, 比如导航栏, 容器页头, 页脚等元素, 会交给一个频道的 代理对象 来处理.

除了固定内容的管理, 还有一些与框架无关联的业务功能, 比如点位获取、站点切换等功能, 也会放到代理对象里边实现, 但不在协议里边体现.

具体代理协议如下:


@protocol LVTPageDelegate <NSObject>

@property (nonatomic, weak) LVTEventCenter *eventCenter;
@property (nonatomic, weak) LVTLayoutQuery *layoutQuery;

@property (nonatomic, readonly) CGFloat containerViewTopInset;
@property (nonatomic, readonly) BOOL hidesBottomBarWhenPushed;
@property (nonatomic, readonly) BOOL showsLoadingIndicator;

@property (nonatomic, strong) MJRefreshHeader *header;
@property (nonatomic, strong) MJRefreshFooter *footer;
...

- (void)setupPageUI;
- (void)configPageWithModel:(id)model;
- ...

@end

代理类型的管理, 与模块管理一致, 共用配置文件, 代理类对象的获取同样通过ClassMapper.

另外, 严格意义上讲, 把这个delegate命名为strategy会更加合适, 它的使用体现是 策略模式. 不同的代理有着不同的实现, 某个频道运行时, 也可能会动态的切换代理对象.

比如, 某次下拉刷新后, 下发的代理ID变了, 即对应的类对象变了, 就会创建新的 策略对象 来替换, 从而产生了不一样的UI或行为表现.

4.4 对象通信

模块之间, 模块与框架间存在相互通讯的需求. 比如在某些模块组件需要知道框架存在的生命周期事件, 以作出对应的操作.

对象间的常见通讯方式有:

  1. 命令模式或Target-Action
  2. 代理模式或回调Callback
  3. 观察者模式

考虑到模块间通讯可以1对多, 而前面两种皆为1对1通讯, 所以我们选择基于ReactiveCocoa或RxJava库, 遵循观察者模式来实现一个囊括所有跨模块事件的共享对象, 以进行集中式管理. 以下称之为 事件中心.

具体来说, 就是把有通信需求模块的相关事件集, 以空方法的形式统统添加到事件中心的共享对象上暴露出来(方法实现为空, 但并非抽象类). 各模块则根据自己的需求, 选择性的订阅共享对象上的事件.

模块通讯方式则为直接调用共享事件中心上已添加好的事件方法, 如下:

4.2 模块组件一节中的两个协议里, 都可见定义了设置事件中心的方法以供框架赋值, 以供组件访问.

4.5 交互图

整个框架核心元素间的交互如下:

上图没有包括具体的交互细节, 补上两张时序图:

  • 某频道页首屏展示的时序图(忽略本地缓存等各种情况):

    注: 模块的数据源(SectionData)会向ClassMapper获取具体的类对象, 具体可见数据源协议

  • 某个框架事件(比如切换界面、滑动等)通过事件中心传递给订阅者的时序图:

    事件传递为同步操作, 哪个线程调用哪个线程触发, 订阅者接收事件的顺序由订阅时的先后顺序决定

4.6 架构一览

上张简版架构图以示频道页模块化后上述提到的元素分别位于哪一层.

层次 "个性"命名 说明 包含模块化元素
4 塔顶(召唤师峡谷) 对外是某个"产品". 对内是某个"载体".
3 纯业务层(外塔) 与具体业务密切相关的一层, 比如具体某个界面 模块化通用的控制器VC、遵循PageDataSource的数据源类
2 模块化"组件"层(中塔) 虚拟出来的一层, 只为更直观. 实际也属于上边的纯业务层. 包括遵循PageDelegate协议、模块组件协议、模块DataSource协议的所有类. 是统一规范的大合集
1 业务功能层(内塔) 依旧与业务相关的一层. 但属于公共的业务功能 模块化定义的接口, 遵循接口的VC基类、"组件"基类, ClassMapper, EventCenter, 其他辅助组件开发的类等
0 基础功能层(水晶) 与业务无关的一层, 换个项目也能用, 具有开源性 通用基础组件等

五、小结

以上便为驴妈妈频道页模块化的大致思路, 思路不复杂, 主要细节繁多, 就不一一展开.

无论何种实现方案, 在灵活满足业务需求的前提下, 同时保证技术上的拓展性, 未来再不断"打怪升级", 都不失为一个较优解.


原文作者: 傅翔

原文地址: mp.weixin.qq.com/s/J5YhTk5gy…

非商业转载, 请注明作者及上述原文地址.