作者:黄玮,来自企业微信客户端团队
当企业微信团队在2024年启动鸿蒙Next版开发时,我们面对的是双重难题:
DataList框架给出了破局答案——通过三重机制构建数字负熵流:
罗马不是一天建成的,我们在开发框架方面,也经历了 发现问题 => 探索方案 => 优化改进 的过程
DataList是一套基于数据驱动的分层隔离框架,整体架构图如下
接下来将从数据流向、分层架构的角度分别对这张图进行讲解
从数据流向的角度,DataList框架可以简单分为Data/List两部分
DataList通过将业务数据到UI数据的转换逻辑独立出来,系统形成了清晰的边界层次:
业务实体层(Repo):负责请求数据,拿到业务数据(保持稳定)
业务逻辑层(ViewModel):处理业务逻辑,负责业务数据到UI数据的转换(保持稳定)
UI数据层(CellData/ViewData):对UI层的抽象(内部适应变化,对外接口稳定)
表示层(Cell):处理具体UI渲染(拥抱变化,适配平台新特性)
相当于MVVM(Model-View-ViewModel)变成了「MVDM」(Model-View-「Data」-ViewModel)
箭头代表依赖指向
这里介绍下UI数据层:
export class TextData extends BaseData {
text?: string | Resource
fontColor?: ResourceColor
fontSize?: number | string | Resource
fontWeight?: number | FontWeight | string
//...
//由Image+Text组成
export class ImgTextCellData extends BaseCellData {
builder: WrappedBuilder<[]> = wrapBuilder(ImgTextCellBuilder)
root: RowData
img?: ImgData //对应Image控件
text?: TextData //对应Text控件
}
由于CellData内不含任何业务代码,所以不受限于业务,天然可以复用。下图是组件复用统计(「现有58个组件,数千次复用」)
这样分层的好处
完美的达成,不在于无可增添,而在于无可删减。 ——《风沙星辰》 安托万·德·圣-埃克苏佩里
❞
梳理一下,开发一个业务需求,哪些部分是无可删减的?其实就是业务相关的部分
这些都是必须由开发者填写的逻辑,这些步骤框架最多只能简化,不能代劳。
比如,我们开发一个极简版本的人员列表,看下对应步骤:
//Repo对应Model层
class DemoContactRepo():IListRepository<DemoContactReq,DemoContactRsp> {
override fun requestData(
req: DemoContactReq,//请求参数
callback: (rsp: DemoContactRsp) -> Unit,//结果回调
errorCallback: (errorCode: Int, errorMsg: Any?) -> Unit//错误回调
) {
//请求数据,返回
ContactService.getContact(req){contacts->
callback(contacts)
}
}
}
//继承自单数据源列表基类,泛型指明请求与返回的业务数据类型
class DemoContactViewModel: SingleListViewModel<DemoContactReq, DemoContactRsp>() {
/**
* 业务数据转为UI数据
*/
overridefun transferData(data: DemoContactRsp): List<ICellData> {
returndata.contacts.map {
ImgPhotoTextImgCellData( //通用组件
dataId = it.id,
photo = PhotoData(url = it.avatar),//一个图片控件
leftText = TextData(text = it.name))//一个文本控件
}
}
/**
* 拉取数据所用的仓库(对应Model层)
*/
overridefun initRepository(): IListRepository<DemoContactReq, DemoContactRsp> {
return DemoContactRepo()
}
/**
* 初次或刷新页面时的请求参数
*/
overridefun refreshParam(arguments: Bundle?): DemoContactReq {
return DemoContactReq(0,20)
}
}
算上注释,「总计39行」,一个极简版联系人列表就开发完成了。
如果是一个本地静态页面,可以去掉网络请求部分,直接堆砌通用组件(CellData)即可,「完整代码只要40行」
//继承自本地静态列表基类,无数据请求
class DemoAttendanceViewModel:LocalSingleListViewModel() {
//...
//🔧 乐高式组件拼装
overridefun transformCellDataList(): List<ICellData> {
return listOf(
attendanceCellData("打卡人员","员工A").section(1),
attendanceCellData("规则名称","打卡规则abc").section(1),
attendanceCellData("规则类型","固定上下班").section(2),
attendanceCellData("打卡时间","周一至周五,09:00-10:00").section(2),
attendanceCellData("打卡方式","手机+智慧考勤机").section(3),
attendanceCellData("打卡位置","天府三街198号").section(3),
attendanceCellData("打卡Wi-Fi", "未设置").section(3),
attendanceCellData("打卡设备", "").section(3),
TextCellData(TextData.tips("位置和Wi-Fi满足任意一项即可打卡")).noneDivider(),
attendanceCellData("加班规则","以加班申请为准").section(4),
attendanceCellData("更多设置","").section(5),
ButtonCellData(ButtonData("删除规则", buttonStyle = R.style.button_l_white, textColor = R.color.day_night_color_chrome_red.getColor())).section(6))
}
//对通用Cell的简单封装
privatefun attendanceCellData(title:String,desc:String):ImgPhotoTextImgCellData{
return ImgPhotoTextImgCellData(/*设置属性*/)
}
}
如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。 ——《整洁架构之道》
❞
通过MVDM分层架构,我们构建了业务逻辑与UI渲染的解耦机制。但真正的考验来自鸿蒙Next开发——当底层API如流沙般变动时,如何保持上层建筑的稳定?
通过UI数据层的隔离,MVDM的UI层历经三个大版本的架构演进,业务层仍保持稳定:
这三次蜕变完美诠释了"流沙筑城"的技术哲学:在持续变化的基础设施上,通过架构设计构建确定性。接下来我们将深入每个阶段的演变历程。
由于我们所有页面都基于DataList开发,需要尽快实现数据绑定能力,让业务开发可以启动。
鸿蒙和Compose一样,UI组件是函数而不是类,没办法像Android那样,拿到控件的对象进行赋值。
@Component
export struct DemoPage{
build(){
Text("Hello World!") //这是一个函数,没法拿到它的对象,也就没法进行动态赋值
}
}
如果要实现数据与UI的绑定,只能在这里对所有属性进行遍历调用
在现有API的基础上,我们只能实现这个方案
直接把所有属性列出来,全部调一遍,如果data里对应属性没有赋值,就相当于用null调用了一次。
这个方案有很多问题:
我们迫切需要一个能动态设置属性的方案,因此我向华为官方提出了需求:
这个需求交付之后,就有了第二版。
之前提的需求,华为给的解决方案是AttributeModifer
这是官网的介绍:
接入AttributeModifer后,UI层的写法如下:
@Component
export struct WwText {
@ObjectLink data: TextData
@State modifier: TextModifier = new TextModifier(new TextData())
aboutToAppear(): void {
this.modifier.data = this.data
}
build() {
Text(this.data.text)
.attributeModifier(this.modifier) //通过modifier更新属性,不必再调其他函数
}
}
这里更新的原理大致如下图:
TextData被@Observed注解之后,实际上是被动态代理了
WwText编译后的ts代码如下
//WWText.ts
export class WwText extends ViewPU {
//...
initialRender() {
this.observeComponentCreation2((elmtId, isInitialRender) => {
//这里就是会刷新的部分
Text.create(this.data.text);
Text.attributeModifier.bind(this)(ObservedObject.GetRawObject(this.modifier));
}, Text);
Text.pop();
}
}
实际使用中发现,这套方案有两方面很显著的问题
在实际应用这些Ww系列封装组件的场景,可以看到编译后的代码膨胀的非常明显,两行编译后变成了二十行
一个通用组件,编译后从「4k变成了75k」
这个写法的性能也非常差,主要是三个方面:
在applyAttribute这里,如果TextData里面设置了10个属性,但是本次只更新了一个属性,那么在触发更新之后,仍然会10个属性都重新设置一遍
export class TextModifier extends BaseModifier<TextAttribute> {
//...
applyAttribute(instance: TextAttribute, data: TextData) {
super.applyAttribute(instance, data)
if (data.fontColor || data.fontColor == 0) {
instance.fontColor(data.fontColor)
}
if (data.textAlign) {
instance.textAlign(data.textAlign)
}
//...
}
}
现在鸿蒙这套状态管理机制,在DataList数据绑定的场景下性能不足。查了一下鸿蒙状态管理机制的源码,状态变量是通过动态代理来感知属性变化的,具体一点就是通过SubscribableHandler来代理属性的set、get等操作,源码如下:
class SubscribableHandler{
get(target,property,receiver){
//...
switch(property){
default:
const result = Reflect.get(target,property,receiver)//反射获取属性
if(/*...*/){
let isTracked = this.isPropertyTracked(target, propertyStr);
this.readCbFunc_.call(this.obSelf_, receiver, propertyStr, isTracked);
}
}
}
}
经过测试,这个get函数的耗时为万次9ms。而我们的Modifier里面恰好有很多if,需要拿值来判断。
简单算一下,一个页面10个cell,每个cell5个Text,每个Text23个属性+45个基础属性
一次刷新get次数 = 10X5X(23+45) = 3400次
3400/10000X9 = 3ms
也就是说,没有执行任何具体逻辑,只是取值判断,就消耗了「3ms」
而鸿蒙120帧率的情况,一帧的渲染时间也只有8.3ms
对原生控件进行包装后(Text ==> WwText),View树里会增加一个节点(橙色)
如果某些情况图方便给外层组件又设置了属性,还会再额外增加一个渲染节点(红色)
比如下面这个组件
Column(){
WwText({data:this.data1}).width("100%")
WwText({data:this.data2})
}
对应的View树如下
「节点从两个变成了五个」,而鸿蒙的渲染性能优化就是要求节点越少越好
第三版的目标就是解决第二版的诸多问题,进行性能优化
针对这些问题,分析的思路如下
前面提到使用包装控件有两个弊端
因此,我们决定去掉包装,使用原生控件,那么有两个问题
这两个问题都可以用 AttributeUpdater来解决,它是AttributeModifier的子类
「划重点」
去掉包装类之后,原本放到包装类里面的基础逻辑,可以放到对应的Updater里面,例如:
升级为Updater之后,如果对应的Data仍然是状态变量,那么我们去get的时候消耗依旧。 这里先解释一下,为什么我们的Data要加@Observed注解
按官方的用法,只有多层嵌套监听的场景才需要@Observed注解
❞
其实这里是因为我们的所有业务逻辑都在ViewModel里面,而不是按照官方方案放在Page里。就会存在修改无法被感知的问题,如下图所示:
说回正题,既然要去掉这个官方的状态管理,那么就有两处改动
那么,如何驱动UI刷新? 正好,AttributeUpdater里面可以直接拿到attribute对象,可以通过这个对象直接设置属性,那么问题就回到了如何感知Data属性的变更。
正常情况首先想到的就是TypeScript的动态代理,即Proxy,鸿蒙的状态管理就是这么做的,其实现基于前文提到的SubscribableHandler,里面用了反射,性能不足。想要不反射,要么就字符串匹配,依次调用对应函数,既然如此,不如彻底一点,直接使用静态代理。
export class BaseData<INS extends CommonAttribute = CommonAttribute>{
//view的实例,由Update赋值和清理
ins?:INS
//用于刷新构造函数
updateConstructorFunc?: () =>void
private _width?: Length
private _height?: Length
//...
set width(width: Length|undefined) {
this._width = width
this.ins?.width(width) //设置属性时直接设置到view上
}
get width():Length|undefined{
returnthis._width
}
//...
最后,配套Updater的实现如下
export class BaseUpdater<DATA extends BaseData, T extends CommonAttribute, C = Initializer<T>> extends AttributeUpdater<T, C> {
data?: DATA
constructor(data?: DATA) {
super()
this.data = data
}
//用于批量刷新所有已设置的属性,上屏或reuse时触发
updateData(data?: DATA, instance?: T): BaseUpdater<DATA, T, C> {
//...
this.setUpdateFunc(this.data, ins)
if (ins) {
this.applyAttribute(ins, this.data)
this.refreshConstructor()
}
returnthis
}
//设置属性
applyAttribute(instance: CommonAttribute, data: BaseData) {
if (data.width || data.width == 0) {
instance.width(data.width)
}
if (data.height || data.height == 0) {
instance.height(data.height)
}
//...
}
}
「第三版的改动总结如下」
这些改动之后,通用组件内部UI层的实现也需修改
@Component
export struct ImgTextCell {
@Consume@Watch("updateData") cellData: ImgTextCellData
rootUpdater = new RowUpdater()
imgUpdater = new ImageUpdater()
textUpdater = new TextUpdater()
aboutToAppear() {
this.updateData()
}
aboutToReuse() {
this.updateData()
}
build() {
Row() {
Image(ImageUpdater.EMPTY).attributeModifier(this.imgUpdater)
Text().attributeModifier(this.textUpdater)
}.attributeModifier(this.rootUpdater)
}
//data与updater绑定
private updateData() {
this.rootUpdater.updateData(this.cellData.root)
this.imgUpdater.updateData(this.cellData.img)
this.textUpdater.updateData(this.cellData.text)
}
}
虽然Cell内部实现变化很大,但是对业务方来说,CellData和Data的对外使用方法没有变化。
其实这里的Cell写法看起来还是有优化空间的,比如你可能会想到,为何不把Data和Updater结合到一起,比如
export class BaseData extends BaseUpdater{
//...
}
然后Cell的写法就可以简化成
@Component
export struct ImgTextCell {
@Consume cellData: ImgTextCellData
build() {
Row() {
Image(ImageUpdater.EMPTY).attributeModifier(this.cellData.img)
Text().attributeModifier(this.cellData.text)
}.attributeModifier(this.cellData.root)
}
}
分两种情况讨论一下
「修改Data内部的值」:这两种写法,都是通过AttributeUpdater内部的attribute对象进行更新,都是改那个更新哪个,没毛病
「增/删/改 Data对象本身」
以PhotoTextCell为例,升级之后代码编译后的体积明显降低了,仅为升级前的「9.3%」
可以再对比下编译后的内容
ets源码
build() {
Row() {
Image("").attributeModifier(this.imgUpdater)
Text().attributeModifier(this.textUpdater)
}.attributeModifier(this.rootUpdater)
}
ts产物
initialRender() {
this.observeComponentCreation2((elmtId, isInitialRender) => {
Row.create();
Row.attributeModifier.bind(this)(this.rootUpdater);
}, Row);
this.observeComponentCreation2((elmtId, isInitialRender) => {
Image.create("");
Image.attributeModifier.bind(this)(this.imgUpdater);
}, Image);
this.observeComponentCreation2((elmtId, isInitialRender) => {
Text.create();
Text.attributeModifier.bind(this)(this.textUpdater);
}, Text);
Text.pop();
Row.pop();
}
可以看到编译产物少了很多层嵌套,代码结构清爽多了,我们的hap当时改完之后体积直接少了「十几M」
升级之后性能也有明显提升
由于鸿蒙的动态帧率机制,118其实就是滑动时满帧
❞
在鸿蒙生态快速迭代的"流沙"环境下,DataList框架通过三重熵减机制构建了确定性开发范式,鸿蒙DataList的三次技术演进本质是一场对抗API不确定性的架构实践:
三次架构升级始终贯彻MVDM分层理念,通过UI数据层的隔离,实现业务逻辑零修改适配UI层巨变。包含这三次主要的升级在内,过去一年DataList的UI层经历了十多次改动(包括API变化与对鸿蒙了解更深入而进行的性能优化)。这些变更揭示了"流沙筑城"的核心逻辑:「表层拥抱变化,中层消化冲击,核心业务层保持稳定」。UI数据层在此场景中负责消化技术变化带来的冲击,允许团队:
这些最终让企业微信鸿蒙团队于2024年底完成了企业微信鸿蒙第一版「100万行,600+页面」的开发,并成功发布。
至此,关于企业微信鸿蒙开发架构演进讲解完毕。