博客 / 詳情

返回

一年擼完百萬行代碼,企業微信的全新鴻蒙NEXT客户端架構演進之路

本文由企業微信客户端團隊黃瑋分享,原題“在流沙上築城:企微鴻蒙開發演進”,下文進行了排版優化和內容修訂。

1、引言

當企業微信團隊在2024年啓動鴻蒙Next版開發時,我們面對的是雙重難題:
1)在WXG小團隊模式下,如何快速將數百萬行級企業應用移植到全新操作系統?
2)在鴻蒙API 還是Preview的初期,如何保持業務代碼的穩定,在API快速更新的浪潮中巋然不動?

DataList框架給出了破局答案(即通過三重機制構建數字負熵流):
1)結構化熵減:將業務邏輯渲染到UI的過程抽象為數據流,使鴻蒙與Android共享同一套數據驅動的開發機制;
2)動態熵減:通過抽象出來的UI數據層屏蔽鴻蒙API的變化,讓業務代碼歷經三個版本的UI層大改而不受影響;
3)認知熵減:將跨平台差異封裝為一系列通用組件,降低開發者心智負荷,可以專注於業務開發而不用關心技術變更。

本文將要分享的是企業微信的鴻蒙Next客户端架構的演進過程,面對代碼移植和API不穩定的挑戰,提出了DataList框架解決方案。通過結構化、動態和認知三重熵減機制,將業務邏輯與UI解耦,實現數據驅動開發。採用MVDM分層架構(業務實體層、邏輯層、UI數據層、表示層),屏蔽系統差異,確保業務代碼穩定。
圖片

2、企業微信客户端框架進化史

羅馬不是一天建成的,我們在開發框架方面,也經歷了 發現問題、探索方案 、優化改進 的過程。

野蠻生長(2019年前):

1)背景:團隊缺乏統一規範,開發風格各異;
2)問題:相同功能重複實現,維護成本高。
初步探索(2019-2022):

1)背景:急需統一開發範式,提高開發效率;
2)實現:EasyList框架,提出"一切皆列表"理念,封裝模板代碼,讓開發者專注於業務開發;
3)問題:未嚴格隔離業務與UI,退化為MVC模式;抽象能力不足,組件複用率極低。
漸入佳境(2022-2024):

1)創新:實現了基於數據驅動/分層隔離的DataList框架;
2)價值:框架提供抽象能力,降低開發認知負擔;讓每一個組件都具備複用能力,極大提高了複用率,助力通用組件從個位數突破至50+。

3、企業微信客户端框架整體設計

3.1 整體架構設計

DataList是一套基於數據驅動的分層隔離框架,整體架構圖如下圖所示。
圖片
▲ 圖1:DataList MVVM架構圖接下來將從數據流向、分層架構的角度分別對這張圖進行講解。

3.2 數據流向設計

從數據流向的角度,DataList框架可以簡單分為Data/List兩部分:
1)List:業務邏輯部分,簡單來説就是業務數據如何轉換為UI數據;
2)Data:數據驅動部分,UI數據如何渲染為實際的UI/如何驅動UI刷新。
圖片
▲ 圖2:DataList數據流向圖

3.3 MVDM環形分層設計

DataList通過將業務數據到UI數據的轉換邏輯獨立出來,系統形成了清晰的邊界層次:
1)業務實體層(Repo):負責請求數據,拿到業務數據(保持穩定);
2)業務邏輯層(ViewModel):處理業務邏輯,負責業務數據到UI數據的轉換(保持穩定);
3)UI數據層(CellData/ViewData):對UI層的抽象(內部適應變化,對外接口穩定);
4)表示層(Cell):處理具體UI渲染(擁抱變化,適配平台新特性)。
相當於MVVM(Model-View-ViewModel)變成了MVDM(Model-View-Data-ViewModel)。箭頭代表依賴指向:
圖片
▲ 圖3:DataList環形分層圖
這裏介紹下UI數據層。將整個控件數據化,即為ViewData:

export class TextData extends BaseData {

text?: string | Resource

fontColor?: ResourceColor

fontSize?: number | string | Resource

fontWeight?: number | FontWeight | string

將多個ViewData組合起來,成為一個組件CellData:

//由Image+Text組成

export class ImgTextCellData extends BaseCellData {

builder: WrappedBuilder<[]> = wrapBuilder(ImgTextCellBuilder)

root: RowData

img?: ImgData //對應Image控件

text?: TextData //對應Text控件

}

由於CellData內不含任何業務代碼,所以不受限於業務,天然可以複用。下圖是組件複用統計(現有58個組件,數千次複用)。
圖片
▲ 圖4:通用組件複用統計
這樣分層的好處:
1)方便UI大規模複用;
2)跨平台代碼一致性;
3)隔離業務與UI,UI層變動不影響業務邏輯。

3.4 無可刪減:DataList開發示例

完美的達成,不在於無可增添,而在於無可刪減。 ——《風沙星辰》 安託萬·德·聖-埃克蘇佩裏

梳理一下,開發一個業務需求,哪些部分是無可刪減的?

其實就是業務相關的部分:

1)數據請求;
2)業務數據轉為UI(UI數據)。
這些都是必須由開發者填寫的邏輯,這些步驟框架最多隻能簡化,不能代勞。

比如:我們開發一個極簡版本的人員列表,看下對應步驟。

數據請求:

//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行」,一個極簡版聯繫人列表就開發完成了。
圖片
▲ 圖5:DataList聯繫人 Demo

如果是一個本地靜態頁面,可以去掉網絡請求部分,直接堆砌通用組件(CellData)即可,完整代碼只要40行。

//繼承自本地靜態列表基類,無數據請求

class DemoAttendanceViewModel:LocalSingleListViewModel() {

//...



//&#128295; 樂高式組件拼裝

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(/*設置屬性*/)

}


}

圖片
▲ 圖6:DataList靜態列表 Demo

3.5 MVDM架構的延遲決策實踐

如果想設計一個便於推進各項工作的系統,其策略就是要在設計中儘可能長時間地保留儘可能多的可選項。 ——《整潔架構之道》

通過MVDM分層架構,我們構建了業務邏輯與UI渲染的解耦機制。但真正的考驗來自鴻蒙Next開發——當底層API如流沙般變動時,如何保持上層建築的穩定?

通過UI數據層的隔離,MVDM的UI層歷經三個大版本的架構演進,業務層仍保持穩定:

1)妥協版:快速啓動業務開發;
2)適配版:擁抱動態屬性能力;
3)優化版:突破性能瓶頸。
這三次蜕變完美詮釋了"流沙築城"的技術哲學:在持續變化的基礎設施上,通過架構設計構建確定性。接下來我們將深入每個階段的演變歷程。

4、第一版:系統限制下的妥協

4.1 目標:快速啓動

由於我們所有頁面都基於DataList開發,需要儘快實現數據綁定能力,讓業務開發可以啓動。

4.2 實現思路

鴻蒙和Compose一樣,UI組件是函數而不是類,沒辦法像Android那樣,拿到控件的對象進行賦值。

@Component

export struct DemoPage{

build(){

    Text("Hello World!") //這是一個函數,沒法拿到它的對象,也就沒法進行動態賦值

}

}

如果要實現數據與UI的綁定,只能在這裏對所有屬性進行遍歷調用.。

4.3 技術方案

在現有API的基礎上,我們只能實現這個方案。
圖片
▲ 圖7:數據綁定第一版
直接把所有屬性列出來,全部調一遍,如果data裏對應屬性沒有賦值,就相當於用null調用了一次。

4.4 實踐問題這個方案有很多問題:

1)即使我在Data裏只設置了一個屬性,也需要執行一遍所有函數;
2)某些屬性函數,用null調用和不調用,表現是不一樣的,這種屬性無法列出;
3)太醜,不優雅。
我們迫切需要一個能動態設置屬性的方案,因此我向華為官方提出了需求。

圖片
▲ 圖8:向華為提需求
這個需求交付之後,就有了第二版。

5、第二版:動態屬性下的數據綁定

5.1 接入動態屬性設置能力

之前提的需求,華為給的解決方案是AttributeModifer。這是官網的介紹:
圖片
▲ 圖9:Modifier能力介紹

5.2 技術方案

接入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更新屬性,不必再調其他函數

}

}

這裏更新的原理大致如下圖:
圖片
▲ 圖10:第二版更新機制
TextData被@Observed註解之後,實際上是被動態代理了:
1)代理類觀察到屬性變化;
2)從記錄的set裏找到觀察者;
3)調用觀察者的更新函數(實際流程比較複雜,很多調用);
4)這個更新函數裏面就會執行Modifier裏面的applyNormalAttribute函數,最後將屬性動態設置到控件上。
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();

}

}

5.3 實踐問題

實際使用中發現,這套方案有兩方面很顯著的問題。
1)問題1:代碼膨脹:
在實際應用這些Ww系列封裝組件的場景,可以看到編譯後的代碼膨脹的非常明顯,兩行編譯後變成了二十行。
圖片
▲ 圖11:ets源碼/ts產物
一個通用組件,編譯後從4k變成了75k。
圖片
▲ 圖12:編譯後體積變化問題
2:性能消耗:這個寫法的性能也非常差,主要是三個方面。
1)冗餘刷新:
在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)

}

//...

}

}

2)狀態管理:

現在鴻蒙這套狀態管理機制,在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。

3)節點增多:

對原生控件進行包裝後(Text ==> WwText),View樹裏會增加一個節點(橙色)。如果某些情況圖方便給外層組件又設置了屬性,還會再額外增加一個渲染節點(紅色)。

比如下面這個組件:

Column(){

WwText({data:this.data1}).width("100%")

WwText({data:this.data2})

}

對應的View樹如下:
圖片
▲ 圖13:節點增多示意節點從兩個變成了五個,而鴻蒙的渲染性能優化就是要求節點越少越好。

6、第三版:基於自定義狀態管理的性能優化

6.1 目標:性能優化

第三版的目標就是解決第二版的諸多問題,進行性能優化。

6.2 實現思路

針對這些問題,分析的思路如下:
圖片
▲ 圖14:第三版問題分析

6.3 技術方案

1)去掉控件包裝:前面提到使用包裝控件有兩個弊端:
1)編譯後的代碼增加,體積增大;
2)增加節點,消耗性能。
因此,我們決定去掉包裝,使用原生控件。
那麼有兩個問題:
1)原本的控件基礎邏輯放哪裏(比如WwPhoto里加載圖片的邏輯);
2)之前提到,我們用AttributeModifier時,控件的屬性函數我們可以動態調用,但是構造函數不行,那如何更新構造函數?
這兩個問題都可以用 AttributeUpdater來解決,它是AttributeModifier的子類。劃重點:
圖片
▲ 圖15: AttributeUpdater説明-劃重點
去掉包裝類之後,原本放到包裝類裏面的基礎邏輯,可以放到對應的Updater裏面。例如:

1)WwText ==> Text + TextUpdater;
2)WwPhoto ==> Image + PhotoUpdater。

2)自定義狀態管理:
升級為Updater之後,如果對應的Data仍然是狀態變量,那麼我們去get的時候消耗依舊。 這裏先解釋一下,為什麼我們的Data要加@Observed註解。

按官方的用法,只有多層嵌套監聽的場景才需要@Observed註解
其實這裏是因為我們的所有業務邏輯都在ViewModel裏面,而不是按照官方方案放在Page裏。就會存在修改無法被感知的問題,如下圖所示。
圖片

▲ 圖16:為何要加@Observed

説回正題,既然要去掉這個官方的狀態管理,那麼就有兩處改動:

1)去掉Data上的@Observed註解;
2)在View裏面不再加狀態註解。
那麼,如何驅動UI刷新?

正好,AttributeUpdater裏面可以直接拿到attribute對象,可以通過這個對象直接設置屬性,那麼問題就回到了如何感知Data屬性的變更。

正常情況首先想到的就是TypeScript的動態代理,即Proxy,鴻蒙的狀態管理就是這麼做的,其實現基於前文提到的SubscribableHandler,裏面用了反射,性能不足。想要不反射,要麼就字符串匹配,依次調用對應函數,既然如此,不如徹底一點,直接使用靜態代理。

export class BaseData

//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> 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)

}

//...

}

}

第三版的改動總結如下:
圖片
▲ 圖17:第三版改動總結
這些改動之後,通用組件內部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的對外使用方法沒有變化。

Data與Updater為何要分開。

其實這裏的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)

}

}

分兩種情況討論一下:
1)修改Data內部的值:這兩種寫法,都是通過AttributeUpdater內部的attribute對象進行更新,都是改那個更新哪個,沒毛病;
2)增/刪/改 Data對象本身。
圖片
▲ 圖18:修改 Data 本身的兩種情況

6.4 升級效果

1)體積降低:
以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」。

2)性能提升:

升級之後性能也有明顯提升:

1)通用組件PhotoTextCell的複用耗時從4.3ms降低到0.9ms;
2)首頁的會話列表,複用的幀率由卡頓的32幀提升到絲滑的118幀。

由於鴻蒙的動態幀率機制,118其實就是滑動時滿幀。

圖片
▲ 圖19:升級前後幀率對比

7、本文小結

在鴻蒙生態快速迭代的"流沙"環境下,DataList框架通過三重熵減機制構建了確定性開發範式,鴻蒙DataList的三次技術演進本質是一場對抗API不確定性的架構實踐。

簡單總結一下:

1)第一版(妥協版):基於API遍歷屬性實現基礎數據綁定,雖快速啓動業務開發但存在冗餘調用與性能隱患;

2)第二版(適配版):引入AttributeModifier動態屬性機制,可進行屬性的動態更新,卻因狀態管理機制本身的性能消耗和控件包裝導致代碼膨脹與性能劣化;

3)第三版(優化版):創新採用自定義狀態管理,剝離包裝層直接操作原生控件,結合AttributeUpdater實現靜態代理與精準屬性更新,使通用組件編譯體積縮減至9.3%、複用耗時降低79%,幀率從32幀躍升至118幀。

三次架構升級始終貫徹MVDM分層理念,通過UI數據層的隔離,實現業務邏輯零修改適配UI層鉅變。包含這三次主要的升級在內,過去一年DataList的UI層經歷了十多次改動(包括API變化與對鴻蒙瞭解更深入而進行的性能優化)。這些變更揭示了"流沙築城"的核心邏輯:「表層擁抱變化,中層消化衝擊,核心業務層保持穩定」。

UI數據層在此場景中負責消化技術變化帶來的衝擊,允許團隊:

1)通過接口抽象延遲具體實現決策;
2)在知識完備後通過實現替換進行漸進式優化;
3)保持核心業務代碼的語義穩定性。
這些最終讓企業微信鴻蒙團隊於2024年底完成了企業微信鴻蒙NEXT第一版「100萬行,600+頁面」的開發,併成功發佈。(本文已同步發佈於:http://www.52im.net/thread-4812-1-1.html)

至此,關於企業微信鴻蒙NEXT開發架構演進講解完畢。

8、相關資料

[1] 微信純血鴻蒙版正式發佈,295天走完微信14年技術之路!

[2] 鴻蒙NEXT如何保證應用安全:詳解鴻蒙NEXT數字簽名和證書機制

[3] 開源IM聊天程序HarmonyChat:基於鴻蒙NEXT的WebSocket協議

[4] 大型IM工程重構實踐:企業微信Android端的重構之路

[5] 企業微信的IM架構設計揭秘:消息模型、萬人羣、已讀回執、消息撤回等

[6] 企業微信針對百萬級組織架構的客户端性能優化實踐

[7] 企業微信客户端中組織架構數據的同步更新方案優化實戰

[8] 微信團隊分享:微信支付代碼重構帶來的移動端軟件架構上的思考

[9] 微信團隊原創分享:微信客户端SQLite數據庫損壞修復實踐

[10] 從客户端的角度來談談移動端IM的消息可靠性和送達機制

[11] 愛奇藝技術分享:愛奇藝Android客户端啓動速度優化實踐總結

[12] 偽即時通訊:分享滴滴出行iOS客户端的演進過程

[13] 移動端IM實踐:Android版微信如何大幅提升交互性能(一)

[14] 百度公共IM系統的Andriod端IM SDK組件架構設計與技術實現

[15] 首次公開,最新手機QQ客户端架構的技術演進實踐

[16] IM開發乾貨分享:有贊移動端IM的組件化SDK架構設計實踐

[17] 馬蜂窩旅遊網的IM客户端架構演進和實踐總結

[18] 蘑菇街基於Electron開發IM客户端的技術實踐

[19] IM開發乾貨分享:我是如何解決大量離線消息導致客户端卡頓的

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.