必會:詳解ArkUI @Builder與Builder設(shè)計模式)
1. 先分清 Builder 的兩種身份不然你后面會越看越亂前陣子在重構(gòu)一個鴻蒙項目的首頁時我發(fā)現(xiàn)團隊里關(guān)于 Builder 的討論特別多。有人說“用 Builder 封裝一個頭部組件”有人說“我的 Builder 實現(xiàn)了一個復(fù)雜對象”還有人拿著一張 SketchUp 的報錯截圖來問是不是 HarmonyOS 的問題。后來我意識到大家口中的 Builder 不只是同一個東西在鴻蒙開發(fā)里它既是 ArkUI 聲明式框架里的Builder 裝飾器又是設(shè)計模式里的建造者模式甚至在一些第三方工具里也出現(xiàn)過同名概念。對于做 HarmonyOS 原生應(yīng)用的人來說最常打交道的其實是 ArkUI 提供的Builder 自定義構(gòu)建函數(shù)。它的核心作用是把一段 UI 描述封裝成一個函數(shù)在多個頁面或組件內(nèi)復(fù)用避免重復(fù)寫同樣的布局代碼。而設(shè)計模式里的 Builder更多是用在 ArkTS 的數(shù)據(jù)建模、復(fù)雜參數(shù)構(gòu)建上跟 UI 不直接相關(guān)但工程上也很實用。所以這篇文章我打算把兩件事串起來講前面五個部分全部圍繞 ArkUI 的 Builder 展開包括全局 Builder、局部 Builder、參數(shù)傳遞、BuilderParam 和尾隨閉包最后一部分再重點聊一下 Builder 設(shè)計模式在 ArkTS 工程中的落地場景。這樣不管你是剛?cè)腴T的新手還是已經(jīng)寫了幾個月鴻蒙頁面但一直沒把 Builder 理清的老手都能找到自己需要的答案。2. 全局自定義構(gòu)建函數(shù)最常用也最容易被忽略的細節(jié)2.1 全局 Builder 的聲明方式全局 Builder 是定義在組件外部的函數(shù)它的作用域是整個模塊。你可以把它當作一段“UI 模板”在任何組件里直接調(diào)用。// GlobalBuilder.ets Builder export function GlobalHeaderBuilder($$: { title: string }) { Row() { Text($$.title) .fontSize(20) .fontWeight(FontWeight.Bold) Blank() Text(更多) .fontSize(14) .fontColor(#999) } .width(100%) .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor(#F5F5F5) }調(diào)用時不需要new也不需要實例化直接在 build 方法里寫Component struct IndexPage { build() { Column() { GlobalHeaderBuilder({ title: 首頁 }) // 其他內(nèi)容 } } }這里的參數(shù)傳遞用了$$語法后面會單獨講但先記住一個結(jié)論如果希望 Builder 內(nèi)部對參數(shù)的修改能同步到外層狀態(tài)或者希望依賴的狀態(tài)變量可以雙向聯(lián)動建議使用$$。2.2 全局 Builder 的優(yōu)勢和坑全局 Builder 最大的優(yōu)勢是跨組件復(fù)用。多個頁面里只要有相同的頁頭、空狀態(tài)、錯誤提示都可以抽出來做成全局 Builder。我習(xí)慣把項目中通用的 UI 片段全部放到一個common/builders目錄下按功能命名比如EmptyStateBuilder、ErrorStateBuilder、PageHeaderBuilder。但全局 Builder 有一個非常容易踩的坑它不能訪問組件內(nèi)部的State變量。因為全局函數(shù)沒有綁定到任何一個組件實例上所以想從組件里把狀態(tài)傳給全局 Builder只能通過參數(shù)傳進去。如果你在全局 Builder 里直接使用this編譯階段就直接報錯。所以我一般只在以下場景使用全局 Builder頁面級通用頭部、底部。下拉刷新、加載失敗、空數(shù)據(jù)等與業(yè)務(wù)狀態(tài)解耦的占位 UI。需要在多個Entry頁面里復(fù)用的純展示型組件片段。如果某段 UI 強依賴當前組件的狀態(tài)、需要調(diào)用當前組件的方法或者需要和組件的生命周期聯(lián)動我的建議是不要用全局 Builder直接用局部 Builder也就是下面第三部分要說的內(nèi)容。3. 組件內(nèi)自定義構(gòu)建函數(shù)狀態(tài)訪問更自由的局部 Builder3.1 在組件內(nèi)部聲明 Builder局部 Builder 就是把Builder函數(shù)寫在組件struct的內(nèi)部。由于它定義在當前組件實例中所以可以直接訪問this包括State、Prop、Link以及普通成員變量和方法。Component struct ProductCard { State productName: string HarmonyOS 實戰(zhàn)指南 Builder ProductCardContent() { Column() { Text(this.productName) .fontSize(16) .fontColor(#333) Text(點擊查看詳情) .fontSize(12) .fontColor(#666) } .padding(12) .backgroundColor(#FFFFFF) .borderRadius(8) } build() { Column() { // 直接調(diào)用 this.ProductCardContent() } .padding(16) } }這個例子雖然簡單但它體現(xiàn)了局部 Builder 最核心的價值Builder 內(nèi)部和組件共享同一個上下文。當你修改productName時Builder 內(nèi)的Text會自動更新不需要你手動同步任何參數(shù)。3.2 局部 Builder 為什么能做到狀態(tài)同步很多初學(xué)者會問this.ProductCardContent()不就是一個函數(shù)調(diào)用嗎為什么狀態(tài)變了它會自動刷新這里要注意ArkUI 的 Builder 并不是普通的函數(shù)。它在編譯階段會被框架特殊處理成為渲染樹的一部分。當組件狀態(tài)變化時框架會依據(jù)狀態(tài)依賴關(guān)系定位到具體的 UI 組件而不是把整個 build 方法重新執(zhí)行一遍。換句話說Builder 內(nèi)部的Text和build()里的組件一樣都建立在相同的狀態(tài)跟蹤基礎(chǔ)之上。這就帶來一個很實用的技巧如果某個區(qū)域的 UI 邏輯特別復(fù)雜或者被if/else、ForEach包裹得很亂可以單獨拆成一個局部 Builder讓代碼可讀性提升不少。但注意局部 Builder 不適合在多個不同組件之間復(fù)用因為一旦 A 組件寫的 Builder 想拿到 B 組件里用就要改成全局 Builder或者提取成公共組件。4. 參數(shù)傳遞默認值、值傳遞和 $$ 引用傳遞一次搞明白4.1 基礎(chǔ)參數(shù)傳遞與默認值Builder 函數(shù)也支持普通參數(shù)和默認值。比如Builder function InfoBuilder(content: string 默認文案, showIcon: boolean true) { Row({ space: 8 }) { if (showIcon) { Text(●) } Text(content) } }調(diào)用時可以不傳參InfoBuilder()也可以傳部分參數(shù)InfoBuilder(自定義文案, false)但這里有兩個規(guī)則要記牢Builder 函數(shù)參數(shù)不能同時使用按引用傳遞和按值傳遞的混寫方式。要么全部用普通參數(shù)值傳遞要么使用$$對象形式引用傳遞。參數(shù)默認值只支持普通參數(shù)類型比如字符串、數(shù)字、布爾值不支持數(shù)組、對象類型。如果默認值是一個動態(tài)狀態(tài)變量那就不能用默認值機制必須顯式傳入。4.2 用 $$ 實現(xiàn)引用傳遞官方推薦的傳參方式之一是把參數(shù)包裝成一個對象并在參數(shù)名稱前加$$。看這個例子Builder function ClickableTextBuilder($$: { count: number }) { Button(點擊次數(shù)${$$.count}) .onClick(() { $$.count }) }父組件里這樣用Component struct CounterPage { State total: number 0 build() { Column() { ClickableTextBuilder({ count: this.total }) Text(父組件計數(shù)${this.total}) } } }當 Builder 內(nèi)部修改了$$.count父組件的total也會跟著變因為這里的$$表示引用傳遞this.total和$$.count指向同一個狀態(tài)源。如果你不用$$直接寫成ClickableTextBuilder({ count: this.total }) // 但函數(shù)定義是普通參數(shù) function ClickableTextBuilder(count: number) { ... }那么 Builder 內(nèi)部拿到的是.total的一個快照不管怎么修改都不會影響父組件。這對某些只讀展示的場景是合理的但如果期望在 Builder 內(nèi)觸發(fā)狀態(tài)更新就必須用$$。我在實際開發(fā)里幾乎都統(tǒng)一用$$對象傳參避免踩“改半天沒反應(yīng)”的坑。4.3 值傳遞和引用傳遞應(yīng)該如何選擇選擇規(guī)則其實很短如果 Builder 只是展示不修改入?yún)⒂闷胀▍?shù)足夠。如果 Builder 內(nèi)部需要修改入?yún)⒉⑼礁附M件用$$。如果參數(shù)是State、Link等狀態(tài)變量的引用建議用$$確保聯(lián)動。如果一個 Builder 有七八個參數(shù)強烈建議全部放進一個$$對象里管理。這樣調(diào)用時的代碼看著像一個配置對象語義清晰也不容易把參數(shù)順序搞錯。5. BuilderParam把 UI 片段當參數(shù)傳進門5.1 理解 BuilderParam 的插槽思想BuilderParam是 Builder 的進階版它解決的場景非常直接父組件想往子組件里塞一段自定義 UI而不是塞一個值。熟悉前端的開發(fā)者一眼就能認出來這就是“插槽”思路。舉個例子有一個通用卡片組件卡片上半部分是固定的標題欄下半部分需要父組件自由填充內(nèi)容。我可以這樣設(shè)計Component export struct CardContainer { BuilderParam contentBuilder: () void build() { Column() { Text(卡片標題) .fontSize(18) .fontWeight(FontWeight.Bold) Divider() // 這里渲染父組件傳入的 UI 片段 this.contentBuilder() } .padding(16) .backgroundColor(#FFFFFF) .borderRadius(12) } }父組件通過BuilderParam把自定義內(nèi)容傳進來Builder function CustomContentBuilder() { Column() { Text(自定義區(qū)域) Button(按鈕) } } Component struct ParentPage { build() { Column() { CardContainer({ contentBuilder: CustomContentBuilder }) } } }這里的contentBuilder類型是() void意思是“一個沒有參數(shù)的構(gòu)建函數(shù)”。父組件傳一個 Builder 函數(shù)進去子組件在合適的位置調(diào)用它。5.2 初始化方式一通過普通參數(shù)傳入第一種是上面這種直接把一個已有 Builder 作為參數(shù)傳入。這種方式在父組件內(nèi)部已經(jīng)定義好一段 UI 時最自然。5.3 初始化方式二尾隨閉包第二種在 API 10 之后非常常用就是“尾隨閉包”寫法。如果子組件的最后一個參數(shù)是BuilderParam可以直接在子組件后面跟一個花括號里面寫 UICardContainer() { // 這里的內(nèi)容會傳給 contentBuilder Row({ space: 8 }) { Text(自定義標題) Image($r(app.media.icon)) .width(24) .height(24) } }這種方式閱讀起來特別像普通布局代碼父組件不需要單獨定義一個 Builder 函數(shù)代碼更緊湊。我個人在寫通用列表項、彈窗內(nèi)容、頁面骨架時非常喜歡這種寫法因為它把“子組件的固定部分”和“父組件的自定義部分”分得很開。5.4 幾個容易踩的初始化細節(jié)BuilderParam有一些隱藏規(guī)則新手經(jīng)常在這里翻車子組件的 BuilderParam 數(shù)量不能太多。雖說不限制數(shù)量但超過兩個后調(diào)用代碼的可讀性會急劇下降。如果確實需要多個插槽建議用普通 Builder 傳參給子組件再在子組件內(nèi)部用 if 處理不同區(qū)域。BuilderParam 參數(shù)名如果在初始化時沒有傳也不會有默認值。如果父組件沒傳contentBuilder子組件調(diào)用this.contentBuilder()時會報錯。因此對于某些可選插槽我會在子組件內(nèi)部提供一個默認 Builder 兜底。尾隨閉包方式和普通參數(shù)方式不能同時使用。一個 BuilderParam 只能選擇一種傳入方式初始化。// 錯誤的寫法既有參數(shù)傳入又寫了尾隨閉包 CardContainer({ contentBuilder: CustomContentBuilder }) { Text(這段閉包不生效) }這種代碼編譯不會直接報錯但閉包內(nèi)容會被忽略排查起來很浪費時間。5.5 實際案例做一個可定制彈窗外殼我項目中有一個通用彈窗組件外層有遮罩、圓角容器、動畫內(nèi)部內(nèi)容完全由調(diào)用方?jīng)Q定。用 BuilderParam 做起來非常清爽Component export struct CommonDialog { BuilderParam dialogContent: () void build() { Stack() { // 遮罩 Column() .width(100%) .height(100%) .backgroundColor(rgba(0, 0, 0, 0.4)) .onClick(() { /* 關(guān)閉邏輯 */ }) // 彈窗內(nèi)容 Column() { this.dialogContent() } .padding(20) .backgroundColor(#FFF) .borderRadius(16) .margin({ left: 24, right: 24 }) } } }使用方只需要傳具體內(nèi)容CommonDialog() { Column({ space: 12 }) { Text(確定要刪除這條數(shù)據(jù)嗎) Row({ space: 20 }) { Button(取消) Button(刪除) } } }這樣彈窗的交互框架和業(yè)務(wù)內(nèi)容完全解耦新增任何彈窗都不需要再改彈窗容器代碼。6. 實戰(zhàn)避坑Builder 狀態(tài)不刷新、循環(huán)構(gòu)建和參數(shù)失效問題6.1 狀態(tài)更新不生效的經(jīng)典場景有網(wǎng)友和我反饋過一個問題在 Builder 里用setInterval修改一個普通變量界面不刷新??吹酱a后發(fā)現(xiàn)他用的是普通成員變量沒有用State裝飾。這是理解上的誤區(qū)Builder 負責(zé)復(fù)用 UI 描述但不負責(zé)“魔法化”所有變量。想要 Builder 里的 UI 感知數(shù)據(jù)變化數(shù)據(jù)源必須是狀態(tài)變量State、Prop、Link、Provide等或者能被框架觀察到的對象屬性。排查 Builder 不刷新問題我一般按這個順序查數(shù)據(jù)變量是否加了State或Observed傳遞參數(shù)是否用了$$引用傳遞如果用了值傳遞Builder 內(nèi)部再改也不會觸發(fā)父組件更新。Builder 內(nèi)部是否使用了this全局 Builder 里訪問不到組件狀態(tài)所以只能靠傳參。如果是對象屬性更新對象是否實現(xiàn)了Observed并且屬性在類內(nèi)部聲明6.2 在 LazyForEach 和列表項中使用 Builder列表頁是 Builder 的高頻場景。很多人把列表項寫成普通Component然后通過ForEach循環(huán)渲染。這沒問題但如果列表項只是一段靜態(tài) UI且不需要獨立狀態(tài)用 Builder 會更輕量。Builder function ProductItemBuilder(item: ProductModel) { Row({ space: 12 }) { Image(item.cover) .width(80) .height(80) .borderRadius(8) Column() { Text(item.name) .fontSize(16) Text(item.price) .fontSize(14) .fontColor(#E84026) } .alignItems(HorizontalAlign.Start) } .width(100%) .padding(10) }在LazyForEach中使用時我會把 Builder 直接放在LazyForEach的子項生成區(qū)域里L(fēng)azyForEach(this.productDataSource, (item: ProductModel, index: number) { ProductItemBuilder(item) })這里有一個經(jīng)驗如果列表項內(nèi)部還需要點擊跳轉(zhuǎn)、需要訪問當前組件的路由方法建議寫成組件而不是 Builder。因為 Builder 里很難方便地處理生命周期和事件上下文強行用 Builder 反而會增加復(fù)雜度。6.3 常見問題速查表現(xiàn)象可能原因推薦解法Builder 內(nèi)部修改數(shù)據(jù)不刷新變量不是狀態(tài)變量改用 State 或 Observed父組件傳入?yún)?shù)后 Builder 里改不動沒有用 $$ 引用傳遞修改參數(shù)為 $$ 對象形式全局 Builder 訪問 this 報錯全局作用域沒有組件實例改為組件內(nèi)局部 Builder 或通過參數(shù)傳入BuilderParam 沒有渲染內(nèi)容初始化方式?jīng)_突或未傳值檢查是否用普通參數(shù)和尾隨閉包混用Builder 內(nèi) ForEach 多次渲染后卡頓每次調(diào)用都創(chuàng)建了新數(shù)組使用 LazyForEach 并確認數(shù)據(jù)源 ID 穩(wěn)定Builder 內(nèi)使用路由方法報錯缺少組件上下文改為 Component 并在 build 中調(diào)用6.4 一個容易被忽略的編譯約束Builder 函數(shù)內(nèi)不能使用Builder裝飾的變量其實不是。但有一個規(guī)則值得注意在 Builder 內(nèi)定義局部狀態(tài)變量是不允許的。比如Builder function WrongBuilder() { State value: number 0 // 編譯報錯 Text(錯誤示例) }狀態(tài)變量必須要放在組件結(jié)構(gòu)體的頂層Builder 只是一個構(gòu)建函數(shù)不能擁有自己的狀態(tài)存儲。如果需要局部狀態(tài)可以新建一個獨立的Component子組件把 Builder 區(qū)域替換成組件調(diào)用。這也是為什么很多時候組件和 Builder 要按場景取舍而不是一味追求“全部用 Builder 封裝”。6.5 代碼組織上的建議項目里 Builder 多起來以后命名和文件組織特別重要。我的習(xí)慣是全局 Builder 文件名用*Builder.ets命名如ListEmptyBuilder.ets。Builder 函數(shù)名用“用途 Builder”后綴如EmptyStateBuilder、ErrorStateBuilder。局部 Builder 放在組件結(jié)構(gòu)體底部和build()方法分開用注釋塊分隔。只在同一個.ets文件里使用的 Builder優(yōu)先做成局部 Builder不導(dǎo)出到全局。7. 順手聊聊 Builder 設(shè)計模式在 ArkTS 工程里的應(yīng)用7.1 數(shù)據(jù)對象構(gòu)造場景ArkUI 開發(fā)中經(jīng)常要構(gòu)造復(fù)雜的請求參數(shù)、表單提交對象、或者一個包含多個配置項的數(shù)據(jù)模型。直接用構(gòu)造函數(shù)傳參參數(shù)一多代碼就變得難讀而且容易傳錯順序。這時候就可以用 Builder 設(shè)計模式。比如有一個UserProfile對象class UserProfile { name: string age: number 0 email: string phone: string address: string }用 Builder 模式封裝后class UserProfileBuilder { private profile: UserProfile new UserProfile() setName(name: string): UserProfileBuilder { this.profile.name name return this } setAge(age: number): UserProfileBuilder { this.profile.age age return this } setEmail(email: string): UserProfileBuilder { this.profile.email email return this } setPhone(phone: string): UserProfileBuilder { this.profile.phone phone return this } build(): UserProfile { return this.profile } }調(diào)用時就很舒服const user new UserProfileBuilder() .setName(張三) .setAge(28) .setEmail(zhangsanexample.com) .setPhone(13800138000) .build()這個寫法在構(gòu)建復(fù)雜對象、DTO、或者測試數(shù)據(jù)時能明顯提升代碼可讀性。7.2 什么時候不要用 Builder 模式如果對象本身只有兩三個字段直接構(gòu)造函數(shù)傳入反而更清晰。Builder 模式最大的代價是代碼量增大、每次構(gòu)建多創(chuàng)建一次臨時對象而且不支持在 Build 方法里再繼續(xù)復(fù)用已有狀態(tài)。所以我的建議是參數(shù)數(shù)量在 5 個以上時優(yōu)先考慮 Builder。需要鏈式調(diào)用、且每個參數(shù)具備默認值時適合 Builder。如果對象是可變對象、需要頻繁修改一部分字段不要用 Builder直接類屬性賦值更簡單。7.3 Builder 設(shè)計模式和 Builder 裝飾器能混用嗎可以。在同一個項目里既有 UI 層面的 Builder也有數(shù)據(jù)層/業(yè)務(wù)層的 Builder 設(shè)計模式。我通常會把數(shù)據(jù)對象構(gòu)建放在model/builders文件里UI 片段構(gòu)建放在view/builders文件里。兩者互不干擾但要注意命名空間不能在同一個文件中定義同名的xxxBuilder類和一個 Builder 函數(shù)否則會沖突。8. 寫在最后的實操提醒上面這些內(nèi)容前五部分是在項目里總結(jié)出來的 ArkUI Builder 核心玩法最后一部分是設(shè)計模式工程化的補充。很多人看著官方文檔覺得 Builder 就是“裝飾器 函數(shù)”但實際寫起來之后關(guān)于參數(shù)傳遞、BuilderParam 的初始化方式、以及全局局部選型這些問題才是真正拉開開發(fā)效率差距的地方。我在實際開發(fā)中的一個體會是Builder 的本質(zhì)是“復(fù)用”但復(fù)用的粒度要控制好。UI 結(jié)構(gòu)完全固定、且不需要交互上下文的優(yōu)先用全局 Builder需要共享組件狀態(tài)、又不想拆組件的用局部 Builder需要讓外部決定某一塊區(qū)域內(nèi)容的用 BuilderParam需要鏈式構(gòu)造復(fù)雜數(shù)據(jù)對象的用設(shè)計模式 Builder。按這個思路去決策基本能覆蓋日常 90% 的場景。最后再分享一個小技巧如果遇到“Builder 內(nèi)部刷新不生效”這種問題先別急著查數(shù)據(jù)先看看你傳進來的是對象還是對象屬性的引用。ArkUI 的狀態(tài)觀察是按照屬性級別追蹤的如果你把一個對象傳入 Builder但對象的屬性不是Observed裝飾的那大概率會出現(xiàn)改了值但界面沒反映的情況。這個坑我至少踩過三次每次排查半天最后都發(fā)現(xiàn)是對象觀察層級的問題。記住這句話能幫你省下不少時間。