歡迎區(qū)域跨端實(shí)戰(zhàn)解析)
把 Flutter 跑在 OpenHarmony 上做應(yīng)用很多人一聽就覺得“又要從零學(xué)一套新的 UI 框架了”。我接某高校宿舍管理系統(tǒng)的跨端需求時(shí)也是這個(gè)反應(yīng)移動(dòng)端要跑 Android樓棟自助終端要跑 OpenHarmony最怕的就是同一套界面寫兩遍。結(jié)果做下來(lái)發(fā)現(xiàn)Flutter 在 OpenHarmony 上的工作方式比想象中順暢真正需要額外處理的是適配層的版本對(duì)齊和幾個(gè)平臺(tái)差異點(diǎn)。這篇我準(zhǔn)備用項(xiàng)目里最典型的“歡迎區(qū)域”作為例子把從跨端選型到組件樹規(guī)劃從核心源碼到實(shí)測(cè)適配的完整過(guò)程拆開講代碼可以直接抄走當(dāng)模板。適合正在折騰 Flutter OpenHarmony 的你也適合想看看一套 UI 代碼如何在不同系統(tǒng)上保持一致體驗(yàn)的團(tuán)隊(duì)參考。1. 為什么是 Flutter OpenHarmony而不是重學(xué)一套 ArkUI先說(shuō)清楚當(dāng)時(shí)面對(duì)的選擇。宿舍管理系統(tǒng)的使用對(duì)象有兩類一類是新生和樓管手里大概率是普通 Android 手機(jī)另一類是放在樓道里的自助終端屏幕大、系統(tǒng)定制跑的是 OpenHarmony。如果按傳統(tǒng)思路走手機(jī)端一套技術(shù)棧自助終端另一套技術(shù)棧UI 還原度、交互細(xì)節(jié)、驗(yàn)收標(biāo)準(zhǔn)全是雙份工作。我當(dāng)時(shí)也認(rèn)真評(píng)估過(guò) ArkTS/ArkUI。它作為 OpenHarmony 的原生方案離系統(tǒng)能力最近如果項(xiàng)目只面向 OpenHarmony 一個(gè)平臺(tái)我會(huì)毫不猶豫選它。但我們的現(xiàn)狀是存量代碼里已經(jīng)有 Flutter 業(yè)務(wù)組件團(tuán)隊(duì)對(duì) Flutter 的掌握也更熟需要的是一條“從 Android 平滑覆蓋到 OpenHarmony”的路線而不是再養(yǎng)一個(gè)平行技術(shù)棧。三個(gè)維度的對(duì)比大概是這樣對(duì)比維度FlutterArkTS/ArkUI團(tuán)隊(duì)上手成本已有 Dart 基礎(chǔ)即可延續(xù)需要重新學(xué)習(xí)聲明式 UI 與狀態(tài)管理雙端覆蓋能力一套代碼可跑 Android / OpenHarmony原生能力只在 OpenHarmony 上有效UI 表現(xiàn)一致性自繪渲染跨端差異小平臺(tái)原生控件系統(tǒng)版本差異需適配OpenHarmony 適配程度已有社區(qū)適配工程能跑但需對(duì)齊版本原生最優(yōu)系統(tǒng)能力調(diào)用依賴插件適配部分能力有缺口直接調(diào)用鏈路短為什么“歡迎區(qū)域”這種界面特別適合用 Flutter 來(lái)做因?yàn)樗举|(zhì)上是自繪型 UI。一個(gè)問候語(yǔ)、一張學(xué)期信息卡片、一列今日待辦、一組快捷入口這些內(nèi)容不依賴系統(tǒng)原生控件Flutter 渲染引擎在 OpenHarmony 上把 Skia 圖形棧跑起來(lái)之后呈現(xiàn)效果和 Android 端可以做到高度一致。越是這種“整頁(yè)都是自定義布局”的界面Flutter 越占優(yōu)勢(shì)。但選 Flutter 不是沒有代價(jià)。OpenHarmony 上的 Flutter 適配工程目前是跟著特定版本走的這意味著你不能無(wú)腦用最新版 Flutter SDK而是要先查適配工程對(duì)應(yīng)的 Flutter tag再基于那個(gè)版本創(chuàng)建工程。否則編譯產(chǎn)物進(jìn) OpenHarmony 設(shè)備后要么跑不起來(lái)要么運(yùn)行一段時(shí)間后隨機(jī)閃退。我們最終是鎖定了一個(gè)經(jīng)過(guò)驗(yàn)證的版本組合把 Flutter SDK 固定下來(lái)升級(jí)前先在測(cè)試機(jī)上完整回歸一遍才敢動(dòng)版本。這一點(diǎn)是后面所有開發(fā)工作的前提也建議你做類似選型時(shí)第一個(gè)確認(rèn)。2. 歡迎區(qū)域拆解把視覺稿“翻譯”成一顆組件樹“歡迎區(qū)域”聽起來(lái)只是一張靜態(tài)歡迎頁(yè)實(shí)際上它承擔(dān)了好幾個(gè)信息層級(jí)先讓用戶看到自己的名字、確認(rèn)身份再獲取他今天需要知道的信息比如樓棟宿舍、輔導(dǎo)員、今日待辦最后給他可操作的動(dòng)作入口比如報(bào)修、簽到、查詢。信息層級(jí)決定了 UI 層級(jí)所以我建議先把視覺稿拆成塊再動(dòng)手寫代碼。我們?cè)谠O(shè)計(jì)稿里把歡迎區(qū)域分成了五塊。最上面是漸變背景的問候頭部里面是姓名、日期和一句狀態(tài)文案下面是學(xué)期信息卡片展示樓棟、宿舍號(hào)、輔導(dǎo)員和報(bào)到狀態(tài)再往下是今日待辦列表底部是四個(gè)快捷入口還有個(gè)裝飾性的品牌落款放在最下方。請(qǐng)注意這里有一個(gè)重要決定歡迎區(qū)域本身不滾動(dòng)。宿舍管理系統(tǒng)的首頁(yè)下方還有業(yè)務(wù)列表如果把所有東西都放進(jìn)一個(gè)滾動(dòng)容器兩個(gè)滾動(dòng)方向會(huì)打架用戶操作體驗(yàn)會(huì)很怪。把視覺塊轉(zhuǎn)成組件樹的過(guò)程就是不斷問“這個(gè)模塊內(nèi)部是橫排還是豎排、需要不需要層疊”的過(guò)程。我們最后得到的結(jié)構(gòu)大致是WelcomeScreen └── SafeArea └── Column ├── GreetingHeader │ └── Stack │ ├── Container (漸變背景 半透明裝飾圓) │ └── Column (問候語(yǔ)) ├── SemesterCard │ └── Container (圓角 陰影) │ └── Row │ ├── Icon │ └── Column (樓棟/宿舍號(hào)/輔導(dǎo)員) ├── TodayNotice │ └── Expanded │ └── ListView / 空狀態(tài) └── QuickActions └── GridView.count這里每個(gè)選擇都有講究。外層用 Column 而不用 ListView是因?yàn)闅g迎區(qū)域高度我們希望控制在單屏內(nèi)Column 足夠且更可控問候頭部用 Stack是為了能在漸變背景上疊一層半透明圓作為裝飾而不影響文字排版學(xué)期信息卡片內(nèi)部用 Row 是因?yàn)椤皥D標(biāo) 多行文本”是最經(jīng)典的組合比堆兩個(gè) Widget 再手動(dòng)對(duì)齊要穩(wěn)今日待辦必須用 Expanded 包住否則在不同屏幕尺寸下很容易出現(xiàn)底部溢出的問題。尺寸策略也值得單獨(dú)說(shuō)。設(shè)計(jì)稿通?;谑謾C(jī)豎屏比例但樓棟自助終端的邏輯分辨率并不一定是標(biāo)準(zhǔn)手機(jī)比例可能接近 4:3 甚至更寬。如果在這些組件里寫死像素值小屏手機(jī)和大屏平板上總有一個(gè)要出問題。所以我們統(tǒng)一約定歡迎區(qū)域只使用相對(duì)布局和約束比例關(guān)鍵容器用 EdgeInsets 統(tǒng)一控制內(nèi)邊距列表區(qū)域用 Expanded 吸收剩余空間不寫死高度只有卡片圓角、圖標(biāo)大小這類視覺常量用固定值。這套約定在后續(xù)適配 OpenHarmony 大屏?xí)r幫了大忙。3. 核心源碼解析歡迎區(qū)域的每個(gè) Widget 到底在干什么這一節(jié)我們直接進(jìn)源碼。我會(huì)按“骨架 → 問候頭部 → 信息卡片 → 待辦列表 → 快捷入口”的順序拆把每個(gè) Widget 存在的理由和坑都交代清楚。3.1 頁(yè)面骨架為什么最外層是 SafeArea Column先看頁(yè)面的總體結(jié)構(gòu)class WelcomeScreen extends StatefulWidget { const WelcomeScreen({super.key, this.userName 新同學(xué)}); final String userName; override StateWelcomeScreen createState() _WelcomeScreenState(); } class _WelcomeScreenState extends StateWelcomeScreen { override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Column( children: const [ GreetingHeader(), SemesterCard(), TodayNotice(), QuickActions(), ], ), ), ); } }最外層放 SafeArea 是必須的。OpenHarmony 設(shè)備以及部分 Android 定制終端的屏幕頂部有系統(tǒng)狀態(tài)欄或圓角區(qū)域不做 SafeArea問候文字會(huì)被裁切或者頂進(jìn)系統(tǒng)欄里。Column 作為主軸容器讓四個(gè)模塊天然保持自上而下的順序。這里沒有用 ListView默認(rèn)歡迎區(qū)域不滾動(dòng)就避免了嵌套滾動(dòng)問題。有人會(huì)問為什么 WelcomeScreen 是 StatefulWidget 但 build 里什么都沒 setState。這是因?yàn)轫?yè)面組件的 state 未來(lái)要接管數(shù)據(jù)加載和刷新當(dāng)前階段只是把靜態(tài)骨架搭好后面接真實(shí)數(shù)據(jù)時(shí)不用切換組件類型。這算是一個(gè)“為下一步留接口”的寫法不算過(guò)度設(shè)計(jì)。3.2 問候頭部Stack 疊加漸變與問候語(yǔ)問候頭部是整個(gè)頁(yè)面視覺的重心也是花心思最多的部分。直接看代碼class GreetingHeader extends StatelessWidget { const GreetingHeader({super.key, required this.userName}); final String userName; String _greetingByHour(int hour) { if (hour 12) return 早上好; if (hour 18) return 下午好; return 晚上好; } override Widget build(BuildContext context) { final hour DateTime.now().hour; final greeting _greetingByHour(hour); return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF2B6CB0), Color(0xFF38B2AC)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: const BorderRadius.only( bottomRight: Radius.circular(40), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text($greeting$userName, style: const TextStyle( fontSize: 28, fontWeight: FontWeight.w600, color: Colors.white, )), const SizedBox(height: 8), Text(歡迎回到宿舍今天也要元?dú)鉂M滿, style: const TextStyle( fontSize: 14, color: Color(0xCCFFFFFF), )), ], ), ); } }這里有幾個(gè)細(xì)節(jié)值得講。顏色直接用 Color(0xFF2B6CB0) 而不是 Colors.blue是因?yàn)槠放粕纸y(tǒng)一后面主題章節(jié)會(huì)再講。問候語(yǔ)按小時(shí)分三種雖然邏輯很簡(jiǎn)單但確實(shí)能提升“這個(gè)頁(yè)面有在認(rèn)真對(duì)待我”的感覺。bottomRight 單邊圓角 40 是有意為之讓頭部和下面的信息卡片之間形成一個(gè)自然過(guò)渡弧線比直上直下柔和很多。Stack 的運(yùn)用我稍微提一下實(shí)際項(xiàng)目里我們還在這個(gè) Container 里疊了一層帶半透明圓形的裝飾做法是在 Stack 中先畫一個(gè)大圓再在上層放文字。如果你也想做類似裝飾建議圓的顏色用 Colors.white 的 5%~10% 透明度的版本視覺效果最干凈不會(huì)搶正文信息。3.3 學(xué)期信息卡片Container 圓角陰影與 Row 信息布局接下來(lái)是學(xué)期信息卡片。它負(fù)責(zé)把樓棟、宿舍號(hào)、輔導(dǎo)員這些高頻信息放在用戶第一眼能看到的位置class SemesterCard extends StatelessWidget { const SemesterCard({super.key, required this.dormInfo}); final DormInfo dormInfo; override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow( color: Color(0x1A000000), blurRadius: 12, offset: Offset(0, 4), ), ], ), child: Row( children: [ const Icon(Icons.apartment, color: Color(0xFF2B6CB0)), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(${dormInfo.building} ${dormInfo.room}), const SizedBox(height: 4), Text(輔導(dǎo)員${dormInfo.counselor}, style: const TextStyle( fontSize: 13, color: Color(0xFF666666), )), ], ), ], ), ); } }這里我想多說(shuō)一句“為什么用 BoxShadow 而不是 elevation”。有些人圖省事直接給 Card 組件加 elevation確實(shí)能出陰影但陰影的模糊半徑、偏移量、顏色完全不受控不同系統(tǒng)上的渲染還略有差異。我們用 Container BoxShadow是因?yàn)榇笃两K端的像素密度和手機(jī)不一樣自定義陰影參數(shù)可以保證在兩種設(shè)備上呈現(xiàn)一致的距離感。DormInfo 只是一個(gè)極簡(jiǎn)數(shù)據(jù)類承載 building、room、counselor 三個(gè)字段。注意這里沒有把網(wǎng)絡(luò)請(qǐng)求塞進(jìn)組件UI 組件只負(fù)責(zé)消費(fèi)外部傳入的數(shù)據(jù)這個(gè)邊界在后面接真實(shí)接口時(shí)非常重要。3.4 今日待辦Expanded 接管剩余空間與空狀態(tài)設(shè)計(jì)今日待辦區(qū)域展示了“今天有什么需要處理的事”本質(zhì)上是一個(gè)可變高度列表。最穩(wěn)的寫法是讓它在頁(yè)面里獲得剩余高度Expanded( child: notices.isEmpty ? const _EmptyNotice() : ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: notices.length, separatorBuilder: (_, __) const SizedBox(height: 8), itemBuilder: (context, index) { final notice notices[index]; return _NoticeRow(notice: notice); }, ), )這里兩個(gè)關(guān)鍵點(diǎn)。第一Expanded 替代固定高度。如果給待辦區(qū)域一個(gè)固定高度小屏手機(jī)會(huì)溢出大屏平板上又浪費(fèi)空間Expanded 讓列表自動(dòng)吃掉卡片下面的剩余高度兩種屏幕都不會(huì)出問題。第二空狀態(tài)不是可選項(xiàng)。宿舍管理系統(tǒng)的場(chǎng)景里新生報(bào)到完成前可能沒有任何待辦如果直接顯示空白區(qū)域用戶會(huì)覺得頁(yè)面壞了所以必須有一個(gè) _EmptyNotice 組件用一張溫和的插圖和一句“今天沒有待辦事項(xiàng)”來(lái)抵消空白感。_NoticeRow 內(nèi)部我用了行內(nèi)圓點(diǎn)作為時(shí)間標(biāo)記左側(cè)一個(gè)小圓點(diǎn)代表優(yōu)先級(jí)右側(cè)是通知標(biāo)題和發(fā)布時(shí)間。這些細(xì)節(jié)看起來(lái)瑣碎但正是歡迎區(qū)域“信息層級(jí)”的體現(xiàn)高優(yōu)先級(jí)任務(wù)通過(guò)顏色差異一眼就能識(shí)別。3.5 快捷入口GridView 與 ActionCard 的拆解底部四個(gè)快捷入口是“報(bào)修”“簽到”“查寢表”“繳費(fèi)”在 UI 上用等寬網(wǎng)格排列GridView.count( crossAxisCount: 4, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 8), mainAxisSpacing: 8, crossAxisSpacing: 8, children: actions .map((action) ActionCard(action: action)) .toList(), )crossAxisCount 用 4是因?yàn)樗娜肟诘膱?chǎng)景在手機(jī)寬度和樓棟終端寬度下都能保持合理的圖標(biāo)尺寸。shrinkWrap: true 是為了讓 GridView 在 Column 里按內(nèi)容高度收縮否則它默認(rèn)會(huì)試圖占滿無(wú)限高度直接導(dǎo)致布局報(bào)錯(cuò)配合 NeverScrollableScrollPhysics徹底關(guān)閉網(wǎng)格自身的滾動(dòng)能力避免和上層滾動(dòng)容器沖突。每個(gè)入口單獨(dú)抽成 ActionCard 組件內(nèi)部就是一個(gè) Icon Text 的縱向組合外加一個(gè)輕量水波紋反饋。抽組件的意義在于后續(xù)如果要給某個(gè)入口加“小紅點(diǎn)角標(biāo)”或“禁用態(tài)”只需要在 ActionCard 內(nèi)部做條件渲染不會(huì)影響頁(yè)面其他代碼。4. 讓歡迎區(qū)域“活”起來(lái)數(shù)據(jù)、狀態(tài)與刷新時(shí)機(jī)靜態(tài)界面跑通只是第一步。歡迎區(qū)域真正的挑戰(zhàn)是把用戶名、宿舍信息、今日待辦這些數(shù)據(jù)正確地填進(jìn)來(lái)并且在合適的時(shí)間刷新。這里我采用了“ViewModel Repository”的分層思路而不是直接在組件里寫網(wǎng)絡(luò)請(qǐng)求。4.1 數(shù)據(jù)邊界UI 組件不碰網(wǎng)絡(luò)在這個(gè)項(xiàng)目里歡迎區(qū)域涉及三類數(shù)據(jù)用戶資料、學(xué)期配置、今日待辦。它們分別來(lái)自本地緩存和遠(yuǎn)端接口。為了讓 UI 層保持干凈我定義了一個(gè)歡迎數(shù)據(jù)聚合接口由 Repository 層把三個(gè)數(shù)據(jù)源合并成一個(gè) WelcomeData 返回。UI 組件只需要知道 WelcomeData 里有 userName、dormInfo、notices、actions 這幾個(gè)字段完全不關(guān)心它背后是緩存還是 HTTP。這樣設(shè)計(jì)最大的好處是后續(xù)接口調(diào)整時(shí)UI 代碼可以做到零改動(dòng)。比如原來(lái)輔導(dǎo)員信息是從用戶資料接口返回的后來(lái)改成學(xué)期配置接口返回只需要改 Repository 的聚合邏輯頁(yè)面組件不用動(dòng)。4.2 狀態(tài)管理為什么用一個(gè)簡(jiǎn)單的 ChangeNotifier歡迎區(qū)域的狀態(tài)其實(shí)不復(fù)雜加載中、加載失敗、加載成功。用 setState 也能寫但一旦涉及“回到前臺(tái)刷新”“下拉重試”“跨頁(yè)面更新待辦數(shù)量”這些場(chǎng)景setState 會(huì)讓組件越來(lái)越臃腫。我最后選擇的是一個(gè)輕量 ViewModelclass WelcomeViewModel extends ChangeNotifier { WelcomeViewModel(this._repository); final WelcomeRepository _repository; WelcomeData? _data; bool _loading true; String? _error; Futurevoid load() async { _loading true; _error null; notifyListeners(); try { _data await _repository.fetchWelcomeData(); } catch (e) { _error 加載失敗; } finally { _loading false; notifyListeners(); } } }選擇 ChangeNotifier 而不是直接上重狀態(tài)管理框架是因?yàn)闅g迎區(qū)域是頁(yè)面級(jí)局部狀態(tài)不值得引入全局 Store。真正進(jìn)入業(yè)務(wù)列表、跨頁(yè)面共享登錄態(tài)之后再考慮全局狀態(tài)管理方案也不遲。用 notifier 的好處是加載邏輯和 UI 渲染完全解耦后面加緩存、加重試機(jī)制都很自然。4.3 三態(tài) UI骨架屏而不是轉(zhuǎn)圈基于 ViewModel 的狀態(tài)頁(yè)面渲染邏輯分成三態(tài)if (viewModel.loading) { return const WelcomeSkeleton(); } if (viewModel.error ! null) { return ErrorRetry(onRetry: viewModel.load); } return WelcomeContent(data: viewModel.data!);這里有個(gè)容易踩坑的點(diǎn)加載中狀態(tài)不要用全屏轉(zhuǎn)圈。歡迎區(qū)域是頁(yè)面首屏如果每次進(jìn)入都先轉(zhuǎn)圈再出內(nèi)容視覺跳動(dòng)感特別強(qiáng)。我們用了一塊和最終布局等價(jià)的灰色骨架屏用戶會(huì)覺得頁(yè)面在正常加載體驗(yàn)平滑很多。骨架屏的做法很簡(jiǎn)單就是幾塊淡灰色圓角矩形占位不需要額外引入組件庫(kù)。錯(cuò)誤狀態(tài)也考慮了很多。自助終端如果網(wǎng)絡(luò)斷了不應(yīng)該白屏或者無(wú)限轉(zhuǎn)圈而是在頁(yè)面中央顯示“加載失敗”和重試按鈕。因?yàn)槲覀兠嫦虻氖撬奚峁芾韴?chǎng)景有時(shí)樓棟終端網(wǎng)絡(luò)不穩(wěn)定重試按鈕遠(yuǎn)比自動(dòng)輪詢靠譜。4.4 刷新時(shí)機(jī)回到前臺(tái)和跨天切換歡迎區(qū)域有兩個(gè)刷新時(shí)機(jī)容易被忽略App 從后臺(tái)回到前臺(tái)時(shí)待辦狀態(tài)可能已經(jīng)變化需要重新拉取日期跨天后問候語(yǔ)應(yīng)從“晚上好”變成“早上好”。第二個(gè)場(chǎng)景如果只靠 build 里的 DateTime.now() 判斷不會(huì)自動(dòng)刷新必須借助生命周期監(jiān)聽。我用了 WidgetsBindingObserver 監(jiān)聽 AppLifecycleState.resumed在每次回到前臺(tái)時(shí)對(duì)比一下上次訪問的日期如果日期變了或者遠(yuǎn)程數(shù)據(jù)標(biāo)記變了就重新調(diào)一次 load()。這套邏輯不復(fù)雜但能把“頁(yè)面看著像是活的”這個(gè)體驗(yàn)做好。順帶說(shuō)一句歡迎區(qū)域的問候語(yǔ)盡量不要用每分鐘定時(shí)器去刷新太消耗資源按“前臺(tái)恢復(fù) 日期變化”兩個(gè)觸發(fā)點(diǎn)刷新就夠了。5. OpenHarmony 平臺(tái)上碰到的幾個(gè)真實(shí)適配問題跑通跨端之后真正的難點(diǎn)開始在適配層浮出水面。這一節(jié)是實(shí)測(cè)過(guò)程中踩到的坑每個(gè)都值得記錄。5.1 編譯與運(yùn)行版本對(duì)齊是第一道關(guān)OpenHarmony 上的 Flutter 不能直接用最新版 Flutter SDK 編譯。我們一開始圖省事本地 Flutter 版本比適配工程要求的版本新了一個(gè)小版本結(jié)果產(chǎn)物安裝到設(shè)備后界面能出但點(diǎn)擊事件偶爾失效。排查了一圈才發(fā)現(xiàn)是適配層和 Flutter 引擎版本不匹配。解決方式很樸實(shí)把 Flutter SDK 固定到適配工程文檔指定的版本重新創(chuàng)建項(xiàng)目再移植頁(yè)面代碼。另外OpenHarmony 設(shè)備的運(yùn)行鏈路也比普通手機(jī)多幾步包括設(shè)備進(jìn)入開發(fā)者模式、配置調(diào)試證書、通過(guò)工具安裝產(chǎn)物。建議第一次跑通時(shí)最好準(zhǔn)備一臺(tái)固定的測(cè)試設(shè)備把所有環(huán)節(jié)完整走一遍之后每次迭代就順暢了。5.2 尺寸與 dpiMediaQuery 不是萬(wàn)能的樓棟自助終端的屏幕比例和手機(jī)差很多有的是 4:3有的接近 16:10而且系統(tǒng) dpi 設(shè)置各不相同。直接用 MediaQuery.of(context).size 去算布局寬度在某些設(shè)備上會(huì)出現(xiàn)卡片被拉伸或者間距失真。我的建議是涉及整頁(yè)布局時(shí)用 LayoutBuilder 拿父容器約束而不是全局屏幕尺寸。比如快捷入口的網(wǎng)格間距用 maxWidth 的 2% 計(jì)算比固定 12 更穩(wěn)。另外歡迎區(qū)域的漸變背景在大屏上容易出現(xiàn)“豎屏漸變”的生硬感建議把漸變方向固定為對(duì)角線視覺上能掩蓋不少比例差異。5.3 中文字體打包字體遠(yuǎn)比依賴系統(tǒng)字體可靠部分 OpenHarmony 定制設(shè)備的中文字體不完整Text 會(huì)出現(xiàn)缺字、方框或者回退異常。尤其像“你”“好”這類常用字一般沒問題但一些生僻字或特定符號(hào)就可能掛掉。我們最后統(tǒng)一走的是“打包字體”路線把一款開源中文字體文件放進(jìn) assets/fonts在 MaterialApp 的 theme 里配置 fontFamily確保所有設(shè)備用同一套字體渲染。這里有一個(gè)性能提示全系統(tǒng)全局替換字體后首幀渲染會(huì)稍微變慢但歡迎區(qū)域本身就是首屏這個(gè) trade-off 是值得的。如果你只在歡迎區(qū)域需要特殊字體也可以只在該區(qū)域單獨(dú)指定 TextStyle(fontFamily: ...)減小影響面。5.4 系統(tǒng)能力調(diào)用先確認(rèn)插件在 OpenHarmony 端可用歡迎區(qū)域的快捷入口里我們?cè)鞠胱觥耙绘I報(bào)修”帶定位功能但在適配層檢查后發(fā)現(xiàn)對(duì)應(yīng)的定位插件在 OpenHarmony 端還沒有完整實(shí)現(xiàn)。強(qiáng)行調(diào)用會(huì)導(dǎo)致崩潰或靜默失敗。我的建議是凡是涉及相機(jī)、定位、藍(lán)牙這類系統(tǒng)能力的插件在排期階段就要逐個(gè)驗(yàn)證 OpenHarmony 端的可用性。如果插件不支持就把入口設(shè)計(jì)成純前端跳轉(zhuǎn) 輔助表單模式先保證功能鏈路完整等適配插件到位后再替換成原生能力調(diào)用。寧可第一步樸素一點(diǎn)也不要在一個(gè)跑不通的坎上卡住整個(gè)頁(yè)面。5.5 性能與動(dòng)畫大屏設(shè)備上的重繪控制歡迎區(qū)域雖然控件不多但漸變背景、卡片陰影、列表滾動(dòng)這些元素疊加起來(lái)在低配置大屏設(shè)備上容易出現(xiàn)幀率波動(dòng)。我做了三件事第一所有靜態(tài)組件盡可能標(biāo)注 const 構(gòu)造減少 build 階段的對(duì)象創(chuàng)建第二用 RepaintBoundary 把漸變頭部包一層避免列表滾動(dòng)時(shí)整個(gè)頭部跟著重繪第三減少 setState 的粒度數(shù)據(jù)刷新只更新對(duì)應(yīng)的內(nèi)容區(qū)域而不是整頁(yè)重建。水波紋反饋在部分 OpenHarmony 設(shè)備上也有些不一致Material 默認(rèn)的 InkWell 在某些定制設(shè)備上幾乎看不到反饋。后來(lái)我在 ActionCard 里加了一層輕量的透明度動(dòng)畫點(diǎn)擊時(shí)圖標(biāo)短暫變淡再恢復(fù)體驗(yàn)就統(tǒng)一了。這種小細(xì)節(jié)往往是實(shí)際跑設(shè)備才能發(fā)現(xiàn)的問題。最后聊聊我的整體感受。這套歡迎區(qū)域真正花時(shí)間的不是寫 Text、Container、GridView 這些控件而是前期把組件邊界、數(shù)據(jù)邊界、平臺(tái)差異想清楚。我在項(xiàng)目里犯過(guò)的最大錯(cuò)誤是把待辦列表數(shù)據(jù)和歡迎卡片狀態(tài)耦合在一個(gè) State 里結(jié)果每次通知刷新都帶著整頁(yè)重繪拆開之后大屏設(shè)備上的幀率明顯穩(wěn)了。如果你也在做類似系統(tǒng)建議從第一天就把 ViewModel 和 Repository 的殼搭好即使現(xiàn)在往里面填的都是靜態(tài)數(shù)據(jù)。后面再接真實(shí)接口、加新入口時(shí)你就會(huì)發(fā)現(xiàn)這些前期約束幫你省下的時(shí)間遠(yuǎn)超想象。