客户端开发一直存在的一个主要矛盾是产品运营日益增长的快速迭代需要同应用市场严格的审核上架周期之间的矛盾。为应对这一矛盾各种客户端动态化的方案层出不穷。本文介绍一种基于 FlexBox 和 Yoga 的动态排版框架( for iOS ),可在线上调整页面样式,同时对新增卡片类型可做到无需跟版本实时更新。

背景

App Store 新应用上架和版本更新必经的人工审核一直让急性子的中国开发者头疼,虽然目前已经大大缩减了审核周期(通常前一天晚上提审,次日上午即可得到结果),但遇到重大 bug 急需修复,或者仅仅是想改动某个 label 的样式,想在某个地方加个 button 都要重新开发和提审的话显然是不现实的。另一方面现在的 APP 页面样式五花八门,以常见的 feeds 流为例:

一个 tableview 常常由文字+图片+视频随机排列组合形成多种样式的 cell, 如果按照传统的方法每新增一种样式就新建一个类继承 UITableViewCell, 非常低效和复杂。这种场景页面的动态化也是刚需。
一个成熟的UI排版渲染引擎至少需要解决以下问题:

  1. 提升开发效率。
  2. 新增和修改 Feed 样式不需要跟版本。
  3. 列表的滑动流畅不应受影响。
  4. 允许扩展自定义样式。

业内终端动态化最著名的两个框架是 ReactNative 和 weex,两个框架的好处是代码复用和跨平台,但带来的问题就是对开发人员的要求挺高的,不仅要懂 js/react/vue, 还要懂安卓和 iOS 开发;对工程的侵入性很大,很多开发人员对 React Native 的缺陷诟病已久。其实如果团队内不是所有人都熟悉js的话,很自然的想法是退而求其次,不必下发 jsBundle,通过后台下发 json/xml 配置文件,终端去解析并在 APP 上生成真实布局。这就是 DynamicCard 实现动态化的核心。先来看看用起来是怎么样的。

使用

首先需要一个排版描述(layoutDic, 限于篇幅,子卡只给出 10002):

"1007": {
    "cardID": 1007,
    "version": 1,
    "minFrameworkVersion": 1,
    "desc": "内容视频卡",
    "flex_direction": "column",
    "subviews": [{
            "margin": [16, 28, 16, 0],
            "subCardID": "10001",
            "is_clickable": 1,
            "name": "userSubCard",
            "type": "UISubCardView"
        },
        {
            "margin": [16, 10, 16, 0],
            "type": "UILabel",
                  "name": "content",
            "font_size": 14,
            "lines": 0,
            "font_color": "#222222",
            "font_name": "PingFangSC-Regular",
            "line_height": 24,
            "letter_space": 0.7,
            "text": "content_text"
        },
        {
            "margin": [16, 16, 16, 0],
            "type": "UIVideoView",
            "name": "video",
            "video_url": "video_url",
            "video_cover": "video_cover",
            "video_width": "video_width",
            "video_height": "video_height",
            "video_auotoPlay": "video_auotoPlay",
            "video_showMute": "video_showMute",
            "corner_radius": 8,
            "background_color": "#ffffff"
        },
        {
            "margin": [16, 12, 0, 0],
            "subCardID": "10004",
            "name": "gameSubCard",
            "type": "UISubCardView"
        },
        {
            "margin": [16, 12, 0, 24],
            "subCardID": "10002",
            "name": "actionSubCard",
            "type": "UISubCardView"
        }
    ]
}
"10002": {
    "cardID": 10002,
    "version": 1,
    "minFrameworkVersion": 1,
    "desc": "内容卡的操作子卡(点赞评论)",
    "flex_direction": "row",
    "align_items": "alignStart",
    "subviews": [{
            "margin": [0, 0, 0, 0],
            "size": [76, 24],
            "type": "UIVirtualView",
            "flex_direction": "row",
            "align_items": "alignStart",
            "subviews": [{
                "margin": [0, 0, 0, 0],
                "size": [0, 24],
                "type": "UIButton",
                "font_color": "#777575",
                "font_color_selected": "#777575",
                "font_size": 12,
                "name": "like",
                "status": "like_status",
                "font_name": "PingFangSC-Light",
                "image_url": "like_picture",
                "direction": "left",
                "horizontal_alignment": "left",
                "content_edge_insets": [5.3, 3.5, 5.3, 3.5],
                "compound_drawable_padding": 8.7,
                "title": "like_count",
                "action": "like_event"
            }]
        },
        {
            "margin": [0, 0, 0, 0],
            "size": [76, 24],
            "type": "UIVirtualView",
            "flex_direction": "row",
            "align_items": "alignStart",
            "subviews": [{
                "margin": [0, 0, 0, 0],
                "size": [0, 24],
                "type": "UIButton",
                "font_color": "#777575",
                "font_color_selected": "#777575",
                "font_size": 12,
                "name": "comment",
                "font_name": "PingFangSC-Light",
                "image_url": "comment_picture",
                "direction": "left",
                "horizontal_alignment": "left",
                "content_edge_insets": [5.3, 3.5, 5.3, 3.5],
                "compound_drawable_padding": 8.7,
                "title": "comment_count",
                "action": "comment_event"
            }]
        }
    ]
}

接着需要一个业务数据描述(dataDic)

{
    "card_id":1007,
    "user_icon":"http://img.jystatic.com/expmepic/2018/07/20/44b83ff79eaaba7a89247a6046b2dc4a_1532092212.png",
    "user_name":"抽筋八骨",
    "user_id":"108395",
    "user_levelImage":"https://img.jystatic.com/expmepic/20180816/w3.png",
    "user_event":"JumpUserDetailPage",
    "time_text":"7月26日",
    "follow_event":"FollowEvent",
    "follow_name":"关注|已关注",
    "follow_status":0,
    "content_id":"post_108395_1532592117598",
    "content_text":"今天推荐一款船新的音乐游戏polytone,感觉自己化身DJ在打碟",
    "video_cover":"https://img.jystatic.com/expmepic/2018/09/19/800efe4c5ae4dd82414a9ec6060190ee_1532590303.1.png",
    "video_width":1920,
    "video_url":"http://video2.jystatic.com/shg_1110_50003_d7459ef7730a4fa78c37976431c2cd2d.f501.mp4?dis_k=1043e9edde940a512d19a2f4432c6767&dis_t=1546874077",
    "video_duration":34,
    "video_height":1080,
    "app_icon":"https://img.jystatic.com/expmepic/2018/07/25/21abecdd40f93c1987fe8f27fd691316_1532504772830148299.png",
    "app_name":"polytone",
    "app_event":"JumpGameDetailPage",
    "like_event":"LikeEvent",
    "like_status":0,
    "like_count":403,
    "like_picture":"icon_like_normal.png|icon_like_highlight.png",
    "comment_count":43,
    "comment_picture":"icon_comment.png",
    "comment_event":"JumpCommentsPage"
}

以上排版数据的结果如下:

看到这个demo,大家应该知道 DynamicCard 是什么东西了,上面的 JSON 文件阅读起来也非常自然、直接。接下来要解决的问题比较多。这里重点描述布局与事件处理、重用、扩展。分别解决 UI 在线调整、性能优化、功能扩充三个方面的问题。

布局

v0. AutoLayout

第一版我们采用了 autolayout 布局,对于每个控件由四个数组控制决定它的约束:

margin、marginView、marginDirection、size

View 层使用TextKit实现图文混排,这样不必再关心控件具体是 UILabel, 还是UITextViewUIImageView等,统一抽象到一个三层架构中进行渲染:NSTextStorage管理文本内容和属性,NSLayoutManagerNSTextStorage对象中取得文本后进行排版,然后把排版之后的文本放到NSTextContainer对象指定的区域上。最后再由一个文本控件从NSTextContainer中取出内容显示到屏幕中。

很快发现由于每个控件都用四个数组才能确定位置,随着卡片样式的丰富,布局字典过于复杂,反而拖累了开发效率,亟需新的布局引擎。

v1. FlexBoxLayout

这里要感谢饿了么团队的两篇博文《由 FlexBox 算法强力驱动的 Weex 布局引擎》《iOS Flexbox 布局优化》,带我们找到了非常适合当下场景的基于 Yoga 引擎的 Flexbox 布局方案 —— FlexBoxLayout,不了解YogaFlexBox的话可以阅读那两篇文章。总之它使得我们可以像安卓开发中 XML 使用五大布局定义视图一样实现 iOS 界面布局,从开头 demo 的 layoutDic 可以看出。

DynamicCard 框架整体设计为 MVVM 架构,每张卡片分配唯一的一个cardID, 代表一种样式。定义CardView作为卡片根容器,用CardModel进行初始化。subViews为该卡片的所有子view,定义基类ViewModel管理 view 的一些基础属性如background_color, corner_radius, border_width, visibility 等,之后我们用工厂方法创建每种具体 view 所绑定的 viewModel,如:

view viewModel
DCLabel DCLabelModel
DCButton DCButtonModel
DCImageView DCImageModel
DCVideoView DCVideoModel

ViewModel用来绑定 view 和 data,以及管理数据的更新。框架的工作原理如下图,对一张卡片的渲染大致分三个流程:

  1. 从统一后台(或者本地缓存)拉取布局字典,从业务后台获取数据字典;
  2. 根据 layoutMap + dataMap 生成所需要的 viewModels 及 cardModel,完成 view-viewModel 的数据绑定;
  3. 使用 FlexBoxLayout 进行布局,最终将卡片展示在屏幕上。


为提升开发效率我们作了两点优化:

  1. 支持子卡, 对应 viewModel SubCardViewModel, 便于组合复用一些模板卡片,如demo 10001,10002,10004 卡;
  2. 支持虚拟视图,对应 viewModel DCVirtualNodeModel, 避免为实现 FlexBox 布局而创建无用 view 占内存。

事件处理

每个viewModel除了layoutDicdataDic外,还有一个actionModel,通过action字段下发对应的点击事件。

路由

本地统一管理一个路由事件表,事件分为两类:

  1. 跳转事件。
    eg. [“JumpCommentsPage” : “jyp://open?page=CommentDetailViewController”]
  2. 自定义事件。
    eg. [“LikeEvent” : “jyp://open?target=ButtonService&&action=onLikeButtonClick:”]

后台为某个 view 的 action 赋值后,本地路由去匹配完整的伪协议链接,根据格式判断是跳转还是点击,注意两种事件都是将所需参数注入dataDic传给路由。 如果是跳转事件很好处理;自定义事件我们则需要让viewModel实时响应其数据变化,与其相关联的其他viewModel也要及时刷新。

KVO

前面讲到 DynamicCard 使用 MVVM 架构,自然是需要 viewModel 监听 model 的属性变化,再控制 view 去刷新UI。图示为 model 层,单例modelManager将维护一个modelList,这里有个前提是 id 之间不能有重复。

以 demo 卡片为例,dataDic 存在 userId、contentId,初始化该卡片cardModel时新建userModelcontentModel插入 modelList, 并为二者添加观察者cardModelcardModel实现observeValueForKeyPath:. 用户点赞后,modelManager更新该卡片 contentModel, 观察者cardModel更新like_statuslike_count值后,刷新对应的 subView.

注意如果app内其它地方卡片2也有该 feed(同一 contentId),则不需新建 contentModel, 将卡片2添加到观察者列表中,在卡片1点赞,卡片2也会实时响应刷新。

重用

重用是 DynamicCard 在设计之初就重点考虑的一个因素,也是不同于其他引擎的最大的特色。例如 React Native 很大的一个缺陷就是不支持 cell 重用,原因:

  1. UITableView 是主线程同步的,为了保证 UI 流畅度,UI 的渲染需要达到60帧/秒,每帧的大致消耗时间保持在16ms之内;
  2. React Native 运行在单独线程,和UI主线程不同步,也就是从 RN Render 到真正调用 native 代码这个过程是异步的,导致从js运行到最后系统渲染的总时间很难做到<16ms.

可以说有了重用这个特性,DynamicCard 才能应用在列表等有高性能要求的场景。重用指的是,在 UITableView 滑动的时候,不同列表项复用同一个 Cell,Cell 里面的视图数据可以重复使用,核心是为了减少创建视图和修改视图树的次数。FlexBoxLayout提供了 UITableView 的一个 category: UITableView+FBLayout, 支持自动高度、布局缓存,contentView 缓存,和自动 cache 失效机制。 简单分析下实现方式。
为每个UITableView提供两种缓存方案:contentView 缓存 和布局缓存(可选)。对外提供 cell view 的构建block, 展示某行 cell 时,先查看 contentView 是否有缓存(以 indexPath 索引),如果有直接返回缓存。没有的话通过 block 获取 view,然后查找 layout 缓存,如果命中直接applyLayoutCache设置 frame,否则applyWithSize计算 frame. 至此 cell 样式确定,然后根据需要可将 layout 或者 contentView 添加到缓存中。

我们进一步封装一个DyCardTableViewController, 提供数据源dataList, 即后台返回的 dataDic 数组。

lazy var dataList = [[String: Any]]()

创建 tableView 并提供fb_setCellContnetViewBlock实现:

tableView.fb_setCellContnetViewBlock(forIndexPath: { [weak self] indexPath -> UIView in
    guard let data = self?.dataList[indexPath.row],
        let cardID = data[DyCardDataKey.cardID] as? String,
        let layoutDic = DCLayoutEngine.shared.getLayoutDicByCardId(cardID) else {
            return DCCardView(frame: .zero)
    }

    let cardModel = DCCardModel(layoutDic: layoutDic, dataDic: data)
    let cardView = DCCardView(cardModel: cardModel)
    cardView.cardClickClosure = self?.cardClickClosure
    return cardView
})

但实际使用中发现开启 contentView 缓存后,列表下拉非常流畅,但上滑加载速度慢,研究源码发现其实是“伪重用”:

//UITableView+FBLayout.m
- (UITableViewCell *)fb_cacheCellForIndexPath:(NSIndexPath *)indexPath {

    // static NSString *kCellIdentifier = @"fb_kCellIdentifier";
    UITableViewCell *cell = [self dequeueReusableCellWithIdentifier:kCellIdentifier forIndexPath:indexPath];
    UIView *reuseContentView = [cell.contentView viewWithTag:contentViewTag];
    [self updateLayoutCacheWtih:reuseContentView toIndexPath:cell.indexPathStorage];

    // 取缓存,或者block创建
    UIView *cellContentView = [self fb_cacheContentView:indexPath reuseContentView:reuseContentView];
    cell.fb_drawsAsynchronously = YES;
    cellContentView.fb_drawsAsynchronously = YES;
    cell.selectionStyle = cellContentView.fb_selectionStyle;
    cell.backgroundColor = cellContentView.backgroundColor;
    cell.clipsToBounds = cellContentView.clipsToBounds;
    [cell setIndexPathStorageWithIndexPath:indexPath];

    [self fb_configContentView:cellContentView forCell:cell];

    return cell;
}

- (void)fb_configContentView:(UIView *)contentView forCell:(UITableViewCell *)cell{
  UIView *removedView = [cell.contentView viewWithTag:contentViewTag];
  [removedView removeFromSuperview];
  [cell.contentView addSubview:contentView];
}

reuseIdentifier只有一个定值,所谓的“重用”只到 contentView 级别,具体的子 view(label\button 等) 仍需由 CardView 创建 -> 填充数据 -> 布局 -> 计算frame, 这显然是不够的。对UITableView+FBLayout进行了改造:

  1. 既然一个cardId代表一种卡片样式,直接以cardId作为reuseIdentifier
  2. ViewModel基类提供updateView()方法,子类实现直接刷新数据而不必新建view, 例如 label 更新 text, imageView 更新 image;
  3. 关闭 contentView 缓存,每个 cell 优先从复用池取,如果有,则 update 数据后计算 frame(or下拉场景直接 apply layoutCache),没有则新建。

优化后的 block 实现:

 tableView.fb_setCellContnetViewBlock(forIndexPath: { [weak self] indexPath, reuseContentView -> UIView in
     guard let data = self?.dataList[indexPath.row],
         let cardID = data[DyCardDataKey.cardID] as? String,
         let layoutDic = DCLayoutEngine.shared.getLayoutDicByCardId(cardID) else {
             return DCCardView(frame: .zero)
     }

     if (self?.base_reuseContent)! {
         if let reuseContentView = reuseContentView as? DCCardView, let cardID = Int(cardID){
             //reuse
             if reuseContentView.cardModel?.cardID == cardID {
                 reuseContentView.cardModel?.update(dataDic:data)
                 return reuseContentView
             }
         }
     }

     let cardModel = DCCardModel(layoutDic: layoutDic, dataDic: data)
     let cardView = DCCardView(cardModel: cardModel)
     cardView.cardClickClosure = self?.cardClickClosure
     return cardView
 })
 self.view.addSubview(tableView)
 tableView.snp.makeConstraints(self.tableViewConstraintWithTopBar(self.navigationBar, nil))
 return tableView
}()

至此,对于一个类似 feeds 的列表,终端只需新建 ViewController 继承DyCardTableViewController, 设置好 tableView 位置等,再从后台拉取 dataDic 数组组装dataList即可,不用再关心每张卡片的样式。效率提升、代码整洁。

扩展

DynamicCard 内置了一些基础渲染控件,比如文字(DCLabel),图片(DCImageView),按钮(DCButton)等。如果这些组件不满足需求,还可以通过扩展组件来完成封装。实现自定义控件的大概流程如下:

实践

DynamicCard 已应用于多个上线项目。终端有一个 manager 负责卡片的版本控制存储。本地预存一份原始布局 json 文件,以提供无网络等异常情况下的默认样式。我们为终端同学提供了一个 CMS 平台,可批量上传or手动添加某个卡片的布局json,首次上传 version = 0, 之后每编辑一次该卡片 version 自动加1,totalVersion 也加1。此外每张卡还有一个值minParseVersion, 表示支持该卡片样式的最低DynamicCard framework 版本。终端kLayoutParseVersion通常是跟随 app 版本升级的,例如某个版本扩展了新的自定义视图,或者解析规则发生了变化等。 APP 运行后,设置定时任务每隔一段时间检查是否有卡片布局更新(比较本地totalVersion是否小于后台),如果有则请求数据,下发的新卡片需满足两个条件:

  1. version > 终端本地卡片 version(or 本地没有该 cardId)
  2. minParseVersion <= kLayoutParseVersion

例如下图只会下发 1002 和 1011 卡:

特点

  1. 动态:这是 DynamicCard 最大的优势,也是开发此框架的初衷。现在所有的布局字典都统一在 CMS 平台配置、编辑、管理版本、下发,数据字典由业务后台下发。终端可以动态接收和实时加载、渲染、刷新数据,卡片的新增和调整摆脱了对终端版本的依赖,APP 具备 native 体验的同时又有类似 H5 的灵活性。
  2. 快速:排版性能与直接书写的排版代码性能相差不大,封装的 TableView 支持自动高度、布局缓存、contentView 缓存、和自动 cache 失效机制,以保障列表页的流畅度(当然,比 AutoLayout 性能好很多)。
  3. 描述型排版:DynamicCard 接收的排版信息是一个字典,不需要写逻辑代码。做成描述型的好处是:
    • 方便维护,不易出错(因为没有代码);
    • 方便存储;
    • 提升开发效率,只要熟悉 FlexBox, 各种布局实现起来要比代码容易得多(此外可以给控件扩展一些属性快速实现效果,比如 UIButton 图文排布)。
var direction: String?                   // 图片位于文字的方位
var compound_drawable_padding: Float?    // 间隙大小

就实际使用感受来讲,DynamicCard 需要后台与安卓/iOS开发共同维护一份数据字典的文档,dataDic key 要求和 layoutDic 中定义的 value 一致,如果有不一致或者找不到的情况控件便无法正常渲染,一定程度上加大了后台的工作量。
相比 weex/RN 下发js脚本,DynamicCard 在事件处理方面显得不够灵活。但接入和学习成本小得多,终端上手快,而 weex/RN 其实是降低了前端开发者入门移动端的门槛。
终端要根据实际场景选择是否采用 DynamicCard,一些样式丰富且频繁变化的界面如feed流非常适合。