携程 Trip.com App 首页动态化探索

马增翼· 2019-09-17
本文来自 携程技术中心 ,作者 马增翼

作者简介:马增翼,高级软件工程师,目前负责 Trip.com App 公共业务研发和迭代,专注跨平台开发技术研究。热爱和拥抱开源社区。

Trip.com 是携程面向国际市场的全新品牌,受全球化战略影响,随着商业化的脚步加快,首页业务针对产品迭代速度、迭代质量、线上效果验证等方面面临新的挑战。


首页无论在哪个 App 体系下都是主要的流量入口,迭代效率和质量,以及研发成本是我们追求和要解决的问题。


在首页业务迭代中我们遇到以下几个痛点:


1)新的业务需求需要跟版本发布:在商业化越来越快的同时,有大量的产品需求需要快速上线验证其效果,目前在一个大版本需要较长时间周期的大背景下,以及iOS AppStore 审核机制的存在,最少也需要 1 天,最慢无限期的 Review 时间。在不断缩小发布周期的大方向下,通过动态下发的方式也是我们探索的一大方向。

 

2)多端的研发成本:在互联网越来越快的趋势下,如何节约人力成本,解放生产力,更好更快的业务需求迭代是我们一直的诉求,多端一致性和快速 UI 搭建也是我们一大痛点。


3)业务埋点维护性和及时性:在验证产品效果的同时,会产生很多难以维护的埋点代码,在页面越来越复杂的情况下,维护成本急剧上升,当线上埋点缺失,产品和运营无法验证产品效果必须等待下个版本搭车上线。


基于以上痛点,我们推出 FoxPage Native 平台,下文将会从方案选择,框架架构,以及对于业务与技术结合思考和实践的经验分享给大家。


名字来源:FoxPage 是 IBU (国际业务部)内部一个在线可视化制作页面的平台。我们决定基于 FoxPage 搭建 Native 平台。


一、框架



1.1、技术选型及思考


首先需要明确定位以及边界,我们需要一个怎么样的框架去解决存在的痛点。


需要的:


  •    由于在首页场景使用,高性能和稳定性是最基本的要求;

  •    为了不跟随版本发布,所以动态性也是要考虑的;

  •    为了解决研发成本,多端渲染也是需要解决的问题;

 

不考虑的场景:


  •    不需要处理复杂的业务逻辑;

  •    不支持动画精细的交互场景;

  •    不考虑多个组件的联动性;

 

通过梳理场景和边界使得目标清晰。我们需要一个跨平台支持动态性并且高性能 UI 渲染框架。


站在自身需求的角度,调研业界成熟的方案得出的结论如下表。


image.png


React Native:动态性高,但是学习成本和性能(加载性能、页面性能)不理想;


Flutter:谷歌的跨平台框架,性能高,但是无动态性;


通过以上的调研,我们打算用 Native 解析 JSON + Flexbox 的方式来作为最终方案。


这么做有什么优势?


1)学习成本低:Flexbox 布局方式被开发广泛接受(内部跨平台技术栈用的多的是 RN);


2)开发成本低:JSON 和 Flexbox(Yoga)都有成熟的高性能可靠的第三库直接使用,加快框架开发速度(在一个月内将框架完成并且上线);


3)兼容性强:Flexbox 完美兼容 Web 端布局的方式,FoxPage 同时支持 Web 端的 DSL 的输出;


4)自定义&扩展强:由于自研,没有包袱,可以在设计上以最符合我们的场景来设计框架;

 

1.2、架构设计


如何做好架构设计,可以先了解下 Chrome 是如何完成一个 HTML 到 UI 的输出。


image.png


那么 Flutter 渲染流程是如何呢?


image.png


通过调研沉淀下我们的渲染流程:


image.png


各个模块的职责清晰且独立:


Downloader:主要负责 DSL 更新与下载。

CacheManager:顾名思义,负责 DSL 的缓存管理。

Parse:这层主要是做 DSL 解析,负责将 JSON 数据组织成节点,供下层使用。

Layout:此层职责为将 Parse 模块解析之后的数据计算布局,生成布局元素。

Draw:此层职责为将 Parse 生成节点设置 Layout 层的布局信息输出 Native 视图树并提交系统完成渲染。


遵守职责化最小原则,每个模块被赋予的职责最小化并且彼此独立,使后续的维护,可以将影响范围限制模块内部,而不影响其他模块的稳定性,增强系统维护性。也使得对模块定制优化提供基础。


下文会继续针对框架内部原理做深入介绍。


1.3、DSL 的定义


数据绑定


想象一下,在我们日常开发中,往往是数据对应一个 UI 元素的显示,需要有一定的绑定数据机制。

{
    "parentId":"cfb87570-82d8-11e9-811f-0906d1cca8d4",
    "name": "image",
    "props": {
        "url":"{{product.imageUrl}}",
    },
    "extendId": "",
    "conditions": [],
    "type": "trip-app.image",
}

变量的格式为{{变量名}},如以上示例:我们的 image 组件中 url 的属性被设置为 product 对象属性中的 imageUrl 的值。

{
    "parentId":"cfb87570-82d8-11e9-811f-0906d1cca8d4",
    "name": "image",
    "props": {
        "url":"{{products[0].imageUrl}}",
    },
    "extendId": "",
    "conditions": [],
    "type":"trip-app.image",
}

数组取值格式为{{数组[Index]}},如上,我们可以通过此方法获取 products 数组中的第一个元素的图片。

事件

在组件触发事件的时侯,我们希望能做一些自定义的事情,如跳转页面,怎么定义呢?

{
    "onClick": {
        "name":"router_call",
        "props": {
            "value": {
            "plugin":"router",
            "method":"openURL",
            "args": {
                "url":"{{products.deeplink}}"
            }
            }
        },
        "type":"function.call",
    }
}

以上示例表示在点击事件中通过 router 中的 openURL 打开了一个新的页面。

条件判断

在某些条件成立才渲染的场景下,我们也提供了条件判断,示例:

{
    "props": {
        "hidden": {
            "name":"render-activity",
            "type": 1,
            "props": {
                "items": [
                    {
                        "key":"{{data.showActivities}}",
                        "operation":"eq",
                        "value":"1"
                    }
                ]
            }
        }
    },
    "type":"trip-app.image",
}

如上示例: image 组件是否隐藏通过{{data.showActivities}} =="1"来控制。

埋点机制

我们还定义了动态埋点的一些规范,如示例:

{
    "name": "image",
    "props": {
        "$traceData": {
            "onClick": {
                "eventName":"home.click.deals.item",
                "data": {
                    "url":"{{data.operatingActivities.0.deeplink}}",
                    "type":"operating_activities"
                }
            }
        }
    },
    "type":"trip-app.image"
}

在 image 组件中声明了点击事件,并且把需要的参数,通过 data 字段一并上传服务端。

1.4、布局

我们的目标是为了解决动态性和多端一致性,那么具备一个完备的布局能力是一个基础要求。通过调研,有以下3种方案可选。

image.png

1)自定义:完全自定义一套规则,实现成本高,布局效率取决于实现程度,所以这边是“中”,因为是自定义,所有通用性是三者最差的,几乎独家专属。


2)Web CSS:实现一套 Web CSS 样式集,可想而知,如果实现这样的一套系统代价是极高的,为了兼容众多的 CSS 样式,布局效率必然会下降,但是此方案通用性也是最佳,多端共享。


3)Flexbox:弹性盒子布局,从 Web CSS 子集发展而来,在 RN 已得到充分证明它的适用性,由于 Yoga 的存在,让我们在实现成本上得到下降。


Yoga 是 Facebook 基于 Flexbox 的跨平台布局引擎开源库,被用于 RN,Weex 等项目中,也证明了其高性能和可靠性。


在实际使用过程中,Yoga 在 Android 中发现了一些问题,不过我们通过定制源码完美解决,并且在实际体验下来 Yoga 完美的胜任了布局的任务。


一些布局示例:

 "props": {
    "$layoutStyle": {
        "position":"absolute",
        "bottom": "12",
        "flexDirection":"column",
        "marginLeft": "8",
        "width": "100%",
        "paddingRight":"16"
    }
}

单位定义


width、height 等实际数字单位定义中,我们定义了如下的数字单位。


image.png


1.5、DSL 解析


解析层,要做的事情比较简单,为了提高性能,并且对于相同的 DSL 模板,只会做一次解析,之后便会把结果做一个缓存,以下的流程图代表着解析流程。


image.png


1.6、视图构建

视图构建相对简单,通过解析层解析之后,每个视图组件都会ViewNode节点一一对应视图在虚拟视图树中的状态,包括了视图布局属性,视图属性等元素信息。

以下为 iOS 代码示例:

/**
 视图节点,映射 FoxPage 中的组件
 */
@interfaceFPViewNode : NSObject
/**
 视图属性
 */
@property(nonatomic, strong) FPViewAttribute *attribute;
 
/**
 子视图
 */
@property(nonatomic, copy) NSArray<FPViewNode *> *children;
 
/**
 绑定关系的值
 */
@property(nonatomic, copy, readonly) NSArray <NSDictionary *> *bindValues;
 
/**
 绑定关系的值
 */
@property(nonatomic, strong) NSMutableArray<NSString *> *conditions;
 
/**
 真正的视图引用
 */
@property(nonatomic, weak) UIView<FPViewComponentProtocol> *view;
 
/**
 添加的父视图
 */
@property(nonatomic, weak) UIView<FPViewComponentProtocol> *superView;
 
@end

在ViewNode树准备好之后,我们会将树传递到渲染层中进行渲染操作,在渲染层中,根据ViewNode节点的类型,通过代理的方式,从注册的组件之中创建出视图实例,配合 Yoga 布局属性,转换到 Native 视图的映射,由系统完成最终的渲染。

1.7、数据更新


在解析渲染完成之后,关于数据流是怎么处理的呢?以下是处理流程图:


image.png


为了优化性能,我们针对 UI 元素有变化的部分做 dirty 处理,会触发 Layout 和 Draw 模块重计算和重绘。


1.8、动态更新


动态更新能力是重要的一环,在云端更新了页面布局或者样式之后,App 需要即时拉取到最新的 DSL 模板。以下是流程中的时序图:


image.png


需要注意几点:


1)App 打包需要把线上目前可用的 DSL 模板打包进 App 中,避免第一次打开 App DSL 模板未下载的时候的空窗口现象;

2)版本升级需要做好数据隔离和清除;

3)DSL 最新版本下发,需要做好 backup 与异常校验;


通过动态更新机制,改变了我们发布需要跟随版本的痛点,有问题,修复之后可以直接下发到用户的 App。


1.9、可视化页面搭建平台


看到这里的看官,心中肯定会有疑问?你们提供变量、布局、事件、埋点追踪,条件,手动来写这很模板复杂度肯定是很高的,不是普通人可以胜任的吧。


是的,我们也意识到了此问题,所以配套了一套可视化的编辑界面。如下面示例图:


image.png


左边是可视化编辑页面,右侧为实际在 App 场景的使用效果,可以看出还原度还是很高的。


属性编辑界面:


image.png


二、页面工程化的转变



通过动态化的转变之后,首页的业务需求开发的工程模式与研发流程也由此发生变化。


在旧模式下,研发人员更加关注业务需求如何实现,首页的业务需求如何在已有的框架体系之内跑起来。新模式下,研发人员更注重的是,业务组件如何设计,如何完成的一个高质量的业务组件。


将研发人员关注的复杂度从面降为点,使得首页的各个业务模块之间的独立性更高,以及更加稳定。模块之间的复用性提升,如其他业务部门也想用广告组件,他们只需要在其页面做些简单的配置。


在组件生态不断补充的未来,各个业务线之间共享彼此的组件模块,想完成一个新的业务或者产品,只需要像乐高积木一样堆砌即可。


三、构建业务运营闭环



在提供技术基础的条件下,我们继续思考技术和业务之间的关系,如何将业务价值最大化,UI 搭建可以通过平台搭建,是不是可以把产品运营同学也一起参与进来,构建一个业务运营闭环。


image.png


1)产品运营同学提出需求;

2)研发人员介入需求开发,开发组件;

3)组件搭建业务上线之后,一站式追踪线上业务价值;

4)根据平台的数据来实时进行运营策略,如修改页面模块,下线模块,添加模块等等;

5)然后反推产品同学提出更合理的产品需求;

 

如何优化链路,科学的运营体系构建运营业务闭环也是重中之中,并且未来会持续不断在此方向上探索。


四、总结



FoxPage Native 平台上线短短 2 个月,承载了 30+ 次的业务调整,5+ 次临时调整埋点的需求,这是之前做不到的。并且合理系统的设计也增加了框架稳定性,上线至今无任何异常发生。在保证的高质量的交付的同时,也大大减少我们研发成本,如一个复杂的展示模块开发,从原来的双端 4 人日降低到双端 1 人日。


在首页动态化的探索中,遇到了很多的挑战,也有很多收获,后续有很多的功能和需求还需要继续优化和完善,并且需要考虑更多的场景支持。我们相信这是一个好的开头。