戰(zhàn):Flex、Expanded與適配技巧)
1. 先弄明白彈性布局到底在解決什么1.1 從固定尺寸到“比例分配”的思路切換很多習(xí)慣了傳統(tǒng)原生布局的開發(fā)者第一次接觸 Flutter for OpenHarmony 時(shí)會(huì)下意識(shí)去找“xx dp 寬度”這種寫法。我剛上手時(shí)也踩過這個(gè)坑在一款適配 OpenHarmony 的跨平臺(tái)應(yīng)用里把三個(gè)卡片寫成了固定寬度 320dp margin 8結(jié)果換到寬屏設(shè)備上左邊對(duì)齊右邊空出一大塊換到小屏設(shè)備上又直接溢出。彈性布局的核心就是把“給每個(gè)組件指定具體像素”換成“給每個(gè)組件指定占比”剩下的交給布局引擎去算。在 Flutter 里這個(gè)占比由 flex 權(quán)重來表達(dá)。你只需要告訴 Row 或 Column某個(gè)子組件占 1 份另一個(gè)占 2 份引擎會(huì)根據(jù)父級(jí)實(shí)際可用空間自動(dòng)換算成像素值。這樣做的好處是界面天然適應(yīng)不同寬高的屏幕不需要寫多套尺寸判斷。Flutter for OpenHarmony 的適配層也承接了這套布局能力所以你在 OpenHarmony 設(shè)備上寫彈性布局跟在其他 Flutter 平臺(tái)上寫幾乎一致省掉了大量重復(fù)適配工作。1.2 OpenHarmony 設(shè)備形態(tài)差異讓彈性布局變成剛需OpenHarmony 的目標(biāo)設(shè)備不只有“手機(jī)”這一種形態(tài)有帶實(shí)體按鍵的小屏設(shè)備、有長條形的交互顯示屏、也有平板和電視。這些設(shè)備的分辨率、密度和顯示區(qū)域差異非常大UI 如果按一套固定寬度設(shè)計(jì)在大部分設(shè)備上都不會(huì)好看。Flutter 的布局引擎本身是基于約束往下傳遞的父級(jí)把可用空間作為約束傳給子級(jí)彈性布局根據(jù) flex 權(quán)重在可用空間內(nèi)分配。這正好對(duì)上 OpenHarmony 多設(shè)備顯示的需求。還有一個(gè)很多人忽略的點(diǎn)OpenHarmony 設(shè)備的系統(tǒng)狀態(tài)欄高度、挖孔區(qū)域、底部手勢(shì)條遮蔽區(qū)也不是統(tǒng)一的。彈性布局不是直接幫你處理安全區(qū)但它保證了你處理完安全區(qū)之后剩余空間能被高效利用。比如在一個(gè)帶挖孔的長屏設(shè)備上外層套 SafeArea內(nèi)層用 Column Expanded 讓滾動(dòng)區(qū)占滿剩余高度底部操作欄永遠(yuǎn)不被鍵盤頂沒。這就是實(shí)戰(zhàn)中彈性布局最大的價(jià)值它讓布局邏輯與屏幕物理參數(shù)解耦你做適配時(shí)只需要關(guān)注“哪些區(qū)域可用”而不是“這個(gè)設(shè)備寬多少”。2. 核心引擎拆解Flex、Row、Column、Expanded、Flexible2.1 Row 與 Column 不過是 Flex 的兩個(gè)特例Flutter 里的 Row 和 Column 并不是兩個(gè)完全獨(dú)立的布局組件它們內(nèi)部都是 Flex只是 direction 參數(shù)不同。Row 是主軸為水平的 FlexColumn 是主軸為垂直的 Flex。理解這一點(diǎn)你就能避免很多“為什么 Row 不換行”“為什么 Column 里的 Expanded 報(bào)錯(cuò)”之類的困惑。// Row 等價(jià)于 Flex( direction: Axis.horizontal, children: [...], ) // Column 等價(jià)于 Flex( direction: Axis.vertical, children: [...], )實(shí)際開發(fā)中直接用 Row / Column 更清晰但遇到需要?jiǎng)討B(tài)橫豎切換時(shí)可以換成 Flex 并動(dòng)態(tài)改 direction。彈性布局相關(guān)的子組件Expanded、Flexible、Spacer只能在 Flex 容器內(nèi)使用因?yàn)樗鼈円蕾?Flex 容器提供的布局協(xié)議。如果你把 Expanded 放在普通 Container 或者 Stack 里控件不會(huì)報(bào)編譯錯(cuò)但運(yùn)行時(shí)會(huì)得到一個(gè)斷言錯(cuò)誤提示你找 ParentDataWidget。記住這條規(guī)則彈性分配必須有 Flex 父級(jí)這是第一層基本功。2.2 Expanded 與 Flexible 的差異tight 還是 loose新手最容易混淆的就是 Expanded 和 Flexible。表面上看起來它們都能按 flex 權(quán)重占空間但行為有本質(zhì)區(qū)別。Expaned 實(shí)際上是 Flexible 的一個(gè)快捷寫法// Expanded 展開后的形式 Flexible( fit: FlexFit.tight, child: ..., ) // Flexible 默認(rèn)形式 Flexible( fit: FlexFit.loose, child: ..., )fit 為 tight 時(shí)子組件必須填滿被分配到的空間fit 為 loose 時(shí)子組件只是“最多可以占這么多”如果子組件自身內(nèi)容更小就按內(nèi)容尺寸顯示。我舉一個(gè)直觀例子如果把一排三個(gè)文本標(biāo)簽放進(jìn) Row 里用 Expanded 包起來三個(gè)標(biāo)簽會(huì)被拉成等寬文本超長就溢出或截?cái)嘤?Flexible 包起來三個(gè)標(biāo)簽會(huì)各自保持自己的文本寬度只在空間不夠時(shí)才壓縮。這里有一個(gè)實(shí)戰(zhàn)建議如果子組件是圖片、卡片這類“希望盡量撐滿”的容器用 Expanded如果子組件是文本、按鈕這類“能小則小、實(shí)在不行才壓縮”的組件用 Flexible 配合 overflow 處理。OpenHarmony 設(shè)備上中文字體渲染寬度不穩(wěn)定尤其是系統(tǒng)字體大小被用戶調(diào)大后文本類組件很容易撐破布局改用 Flexible loose 是性價(jià)比很高的修法。2.3 flex 剩余空間的計(jì)算邏輯很多教程只告訴你 flex 是按比例分配卻沒說清楚具體怎么算。Flex 容器在布局時(shí)會(huì)先測(cè)量所有“非彈性子組件”的尺寸比如一個(gè)固定寬度的圖標(biāo)、一個(gè) SizedBox、一個(gè)沒有 flex 的文本。剩余空間 主軸可用總長度 - 非彈性子組件占用的長度然后這個(gè)剩余空間再按所有彈性子組件的 flex 權(quán)重比例去分。假設(shè)一個(gè) Row 寬度為 400里面有一個(gè)固定寬度 100 的按鈕還有兩個(gè)組件flex 分別為 1 和 2。那么剩余空間是 300第一個(gè)彈性組件分到 100第二個(gè)分到 200。如果按鈕改成了 flex:0 但自身內(nèi)容寬度 150邏輯也是一樣的先量出 150剩余 250 再按比例分。理解這個(gè)流程后你就能解釋為什么給 Row 中間加一個(gè) Spacer 會(huì)把兩端組件推到兩邊Spacer 本質(zhì)是 Expanded 的空白占位它參與剩余空間分配把剩余空間“吃掉”了。Row( children: [ const Text(左側(cè)), const Spacer(), // 等價(jià)于 Expanded(child: SizedBox.shrink()) const Text(右側(cè)), ], )上邊這段代碼里兩個(gè)固定文本測(cè)完尺寸后剩余空間全部給 Spacer于是左右文本被推到兩端。靈活運(yùn)用這個(gè)計(jì)算邏輯比死記一堆布局 API 高效得多。3. 實(shí)戰(zhàn)三個(gè)高頻場(chǎng)景一步到位3.1 搜索框 按鈕的伸縮組合搜索框加“搜索”按鈕是幾乎所有應(yīng)用都會(huì)出現(xiàn)的布局。傳統(tǒng)寫法容易把搜索框固定為“屏幕寬度減按鈕寬度再減邊距”在 OpenHarmony 不同屏幕密度下還得反復(fù)調(diào)。用彈性布局后只需要一行配置讓搜索框 Expanded按鈕保持自身體積。Widget buildSearchRow() { return Padding( padding: const EdgeInsets.all(8), child: Row( children: [ Expanded( child: TextField( decoration: const InputDecoration( hintText: 搜索關(guān)鍵字, isDense: true, border: OutlineInputBorder(), ), ), ), const SizedBox(width: 8), FilledButton( onPressed: () {}, child: const Text(搜索), ), ], ), ); }這里的關(guān)鍵是按鈕不要包 Expanded否則按鈕寬度也會(huì)跟著父級(jí)一起變會(huì)被拉得很奇怪。SizedBox(width: 8) 是固定間距不參與彈性分配所以它不會(huì)影響兩邊的最終尺寸只是簡(jiǎn)單地把輸入框和按鈕隔開。實(shí)際在 OpenHarmony 設(shè)備上跑搜索框高度我建議用 InputDecoration 的 isDense contentPadding 控制別給 TextField 外層寫死高度因?yàn)椴煌斎敕ǖ暮蜻x欄彈出規(guī)則不一樣寫死高度容易在字體縮放后出現(xiàn)點(diǎn)擊區(qū)域錯(cuò)位。3.2 三等分卡片布局與間距處理做設(shè)備管理頁、統(tǒng)計(jì)面板時(shí)經(jīng)常需要三張卡片橫向等分。最直接的辦法是三個(gè) Expanded每張卡片 flex 為 1。但如果想在卡片之間加間距直接在卡片組件外層寫 margin 可能會(huì)導(dǎo)致三張卡片不等寬因?yàn)?margin 占的空間不參與相等分配。這里有個(gè)經(jīng)驗(yàn)等分布局用 padding 代替 margin或者把間距抽出來寫在 SizedBox 中。Row( children: [ Expanded(flex: 1, child: _PanelCard(title: 在線設(shè)備)), const SizedBox(width: 12), Expanded(flex: 1, child: _PanelCard(title: 告警信息)), const SizedBox(width: 12), Expanded(flex: 1, child: _PanelCard(title: 今日產(chǎn)量)), ], )上面這段代碼里兩個(gè) SizedBox 固定占掉 24 的寬度剩下三個(gè) Expanded 均分剩余寬度因此三張卡片本身寬度嚴(yán)格相等。如果你把間距放到 _PanelCard 內(nèi)部 margin 中三個(gè)卡片的“布局寬度”仍然相等但實(shí)際卡片框會(huì)因?yàn)?margin 互相擠占出現(xiàn)間隙視覺效果偏窄。這個(gè)細(xì)節(jié)在調(diào)試時(shí)很隱蔽我建議一律用 SizedBox 來控間距讓彈性子組件只負(fù)責(zé)內(nèi)容區(qū)域。3.3 內(nèi)容滾動(dòng)區(qū) 底部操作欄的吸底布局詳情頁、聊天頁最常見的結(jié)構(gòu)是上方大塊可滾動(dòng)內(nèi)容底部一個(gè)固定操作欄。在原生開發(fā)里要算剩余高度在 Flutter for OpenHarmony 里用 Column Expanded 一次性搞定。Expanded 包住滾動(dòng)區(qū)域讓滾動(dòng)區(qū)域自動(dòng)吃掉所有剩余高度底部操作欄按自身的自然高度靠底。Scaffold( body: SafeArea( child: Column( children: [ Expanded( child: ListView.builder( padding: const EdgeInsets.all(16), itemCount: items.length, itemBuilder: (context, index) _MessageItem(item: items[index]), ), ), const BottomActionBar(), ], ), ), )這個(gè)寫法的好處是不管內(nèi)容多還是少底部操作欄永遠(yuǎn)保持在可視區(qū)域底部不會(huì)因?yàn)閮?nèi)容太少而跑到屏幕中間。要注意的是 Column 需要有一個(gè)有界的高度約束。如果整個(gè)頁面放在一個(gè)自身高度無限的容器里比如直接在 ListView 中放這個(gè) ColumnExpanded 會(huì)報(bào) “non-zero flex but incoming height constraints are unbounded”。所以在 OpenHarmony 的實(shí)際項(xiàng)目里滾動(dòng)頁面要么用 Scaffold 直接管理要么把這個(gè) Column 放到有界高度容器中不要在無界滾動(dòng)視圖里強(qiáng)行用 Expanded。3.4 用 Spacer 控制“留白”而不是死寫邊距有時(shí)候界面需要讓按鈕之間保持固定比例的空隙或者在某個(gè)固定文字后面留一塊自動(dòng)伸縮的空白。很多人第一反應(yīng)是給容器加 padding 或 margin結(jié)果在不同屏幕下要么空白太大要么太小。用 Spacer 是最干凈的方案它不是固定寬度而是按 flex 權(quán)重吃掉剩余空間。Row( children: [ const Text(版本信息), const Spacer(flex: 2), Text(V${_version}), const Spacer(flex: 1), IconButton( onPressed: _copyVersion, icon: const Icon(Icons.copy), ), ], )這里的兩個(gè) Spacer 會(huì)按 2:1 分配剩余空間所以“版本信息”左側(cè)沒有空白右側(cè)大空白是左側(cè)的兩倍。這種寫法在適配不同屏幕時(shí)非常穩(wěn)因?yàn)槟悴恍枰P(guān)心具體空白像素只需要維護(hù)比例關(guān)系。我在 OpenHarmony 控制面板上常用這個(gè)技巧處理頂欄信息比寫死 EdgeInsets 要省心得多。4. 踩坑與排查這些錯(cuò)誤我都見過4.1 RenderFlex overflowed 的排查思路第一次在 OpenHarmony 模擬器上跑 Flutter 布局時(shí)看到一條黃色黑條警告 “RenderFlex overflowed by 23 pixels on the right” 是很常見的。這條報(bào)錯(cuò)的意思是某個(gè) Flex 容器里的子組件所需最小寬度或高度已經(jīng)超過了父級(jí)給的約束彈性分配也救不回來。很多人第一反應(yīng)是“調(diào) flex 權(quán)重”但真正原因往往不是權(quán)重不夠而是某個(gè)子組件設(shè)置了固定寬度或固定高度把剩余空間吃掉了。我的排查順序是先在報(bào)錯(cuò)信息里找到具體是哪個(gè) RenderFlex然后看它的子組件里有沒有固定尺寸的 Container、SizedBox 或帶 padding 的文本。比如一個(gè) Row 里左邊 Expanded 放了一段長文本右邊 IconButton 固定 48 寬如果父級(jí)寬度只有 100即使 Expanded flex 很大子組件里長文本的最小寬度也可能會(huì)把 Row 撐爆。這種情況下更需要給文本加 maxLines overflow: TextOverflow.ellipsis而不是繼續(xù)調(diào) flex。4.2 嵌套滾動(dòng)視圖導(dǎo)致的 unbounded 約束彈性布局失效的另一個(gè)高頻原因是“約束無界”。Flutter 的約束有兩種有界約束是有最大值和最小值的比如 SizedBox(width: 300)無界約束是最大值無限大的比如 ListView 在滾動(dòng)方向上給子組件的就是無界約束。如果在水平滾動(dòng)的 ListView 里放一個(gè) RowRow 里的 Expanded 就會(huì)直接報(bào)錯(cuò)因?yàn)樗椒较驅(qū)挾葻o限彈性分配沒有意義。正確的做法是給這個(gè) Row 一個(gè)確定的最大寬度或者干脆不要放在水平滾動(dòng)視圖里。如果是垂直滾動(dòng) ListView 里需要橫向等分布局就可以把 Row 放進(jìn)一個(gè)寬度占滿父級(jí)的 SizedBox 中用 MediaQuery 的邏輯寬度作為約束。OpenHarmony 上部分設(shè)備橫豎屏切換很頻繁切換后約束寬度會(huì)變建議用 LayoutBuilder 獲取實(shí)際寬度而不是緩存上次的值。4.3 鍵盤彈起把彈性布局搞塌OpenHarmony 設(shè)備上輸入框彈出鍵盤后Flutter 默認(rèn)會(huì)把底部操作欄頂起來。如果你的頁面是 Column Expanded 底部操作欄鍵盤彈出時(shí)剩余高度變小Expanded 會(huì)把滾動(dòng)區(qū)壓得很扁底部操作欄被頂?shù)芥I盤正上方體驗(yàn)很怪。更麻煩的是部分設(shè)備輸入法會(huì)動(dòng)態(tài)改變 insets導(dǎo)致布局在輸入時(shí)出現(xiàn)反復(fù)跳動(dòng)。解決方法不是去掉 Expanded而是按業(yè)務(wù)需要決定底部操作欄是否跟隨鍵盤。如果底部操作欄是“評(píng)論發(fā)送”這種功能鍵盤彈出時(shí)它應(yīng)該跟著上移如果底部操作欄是“確認(rèn)購買”這種與輸入無關(guān)的操作則應(yīng)保持吸底。項(xiàng)目里可以這樣處理外層 Scaffold 設(shè)置 resizeToAvoidBottomInset: true底部操作欄用 AnimatedPadding 監(jiān)聽 MediaQuery.viewInsets.bottom按需把底欄高度抬起來。注意別在 build 里直接讀 viewInsets 做同步計(jì)算最好用 addPostFrameCallback 或監(jiān)聽器避免鍵盤動(dòng)畫期間每幀都觸發(fā)重建。4.4 常見問題速查表現(xiàn)象原因解決辦法RenderFlex overflowed by xx pixels子組件最小尺寸超過可用空間檢查固定寬高、加 TextOverflow.ellipsis、改用 FlexibleColumn children have non-zero flex but incoming height constraints are unbounded在無界滾動(dòng)視圖中使用 Expanded放到有界容器或用 LayoutBuilder 限制高度Row 里的 Expanded 不生效寬度始終由內(nèi)容決定Row 被橫向滾動(dòng)父級(jí)給出無界寬度給 Row 外層限定寬度或改用 WrapListView 在彈性容器里內(nèi)容顯示不全Expanded 分配高度被內(nèi)部 shrinkWrap 干擾確認(rèn) Expanded 高度約束避免同時(shí)設(shè)置 shrinkWrapSpacer 在 Flex 容器外使用異常ParentDataWidget 斷言錯(cuò)誤確認(rèn) Spacer 在 Row/Column/Flex 中文本換行后行高跳動(dòng)彈性容器內(nèi)文本寬度不斷變化用 Flexible loose maxLines或固定行數(shù)這張表是我自己排查時(shí)總結(jié)的未必覆蓋所有場(chǎng)景但覆蓋了我實(shí)際遇到的大部分布局問題。遇到新問題時(shí)要先判斷是“約束問題”還是“尺寸問題”約束問題優(yōu)先改容器層級(jí)尺寸問題優(yōu)先改組件內(nèi)部約束。5. 結(jié)合 OpenHarmony 適配的彈性布局幾個(gè)心得5.1 先確認(rèn)約束來源再動(dòng)手調(diào) flex在 OpenHarmony 多設(shè)備上做適配我在實(shí)戰(zhàn)里學(xué)到最重要的一件事改代碼前先想清楚約束來自哪里。一個(gè)頁面最外層是 ScaffoldScaffold 給 body 的約束是屏幕安全區(qū)尺寸body 里如果有 PaddingPadding 會(huì)縮小約束再往里放 ColumnColumn 會(huì)把水平方向的有界約束傳給子組件垂直方向則取決于 mainAxisSize 和子組件自身。Flex 容器只能在有界的主軸方向上做彈性分配所以當(dāng)你看到 Expanded 無效時(shí)八成是主軸約束無界。建議遇到布局問題時(shí)先打開調(diào)試工具看一眼 Widget 樹確認(rèn)報(bào)錯(cuò)指向的 Flex 容器父級(jí)是否真的有界。我經(jīng)常遇到一種情況Column 放在 SingleChildScrollView 里Column 里的 Expanded 報(bào)錯(cuò)原因就是 SingleChildScrollView 的垂直方向無界。這時(shí)候不一定要換掉滾動(dòng)視圖可以把 Column 放在一個(gè)高度確定的 Stack 里或者在 SingleChildScrollView 外再包一層 IntrinsicHeight雖然 IntrinsicHeight 有額外成本但頁面結(jié)構(gòu)簡(jiǎn)單時(shí)是最省事的方案。5.2 文本類組件在彈性容器里的處理技巧文本是彈性布局里最容易“翻車”的成員因?yàn)橹形淖址?OpenHarmony 不同字體縮放級(jí)別下寬度變化很明顯。一個(gè)彈性容器里放多個(gè)文本組件時(shí)我建議每個(gè)文本都做三件事設(shè)置 maxLines、設(shè)置 overflow、必要時(shí)用 Flexible 而不是 Expanded。如果文本在一條 Row 里要占剩余空間但內(nèi)容太多時(shí)又希望它能截?cái)喽皇前雅赃叺膱D標(biāo)擠沒正確寫法是Row( children: [ Flexible( child: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 4), const Icon(Icons.arrow_forward_ios, size: 16), ], )Flexible 的 loose 讓文本在空間充足時(shí)保持自然寬度在空間不足時(shí)最多占剩余空間配合 maxLines 和 overflow文本不會(huì)把旁邊的圖標(biāo)擠出屏幕。這個(gè)組合在 OpenHarmony 設(shè)置項(xiàng)、列表項(xiàng)里非常實(shí)用幾乎能解決所有“長標(biāo)題 右側(cè)箭頭”這類布局的溢出問題。5.3 用 Debug 工具把“看不見的約束”打出來最后分享一個(gè)我最常用的調(diào)試習(xí)慣。Flex 布局鋪開之后單純靠眼睛定位約束問題很慢。Flutter 開發(fā)模式下打開 Debug 調(diào)試點(diǎn)擊控件可以高亮顯示布局范圍再配合 Flutter Inspector 的“Select Widget”查看父子組件鏈能直接看到父級(jí)傳遞下來的約束。在 OpenHarmony 適配包里這套工具同樣可用只是部分版本需要手動(dòng)打開 render tree 開關(guān)。如果施工現(xiàn)場(chǎng)不方便用可視化工具還有一個(gè)土辦法在可疑組件外層臨時(shí)包一個(gè) ColoredBox用不透明顏色把每個(gè)子組件的邊界畫出來。打過一次顏色后誰占的空間大、誰被壓扁、誰溢出一眼就能看出來。定位完問題再刪掉調(diào)試色塊比長時(shí)間讀源碼分析快得多。我在項(xiàng)目里經(jīng)常用這個(gè)方式處理彈性布局疑難問題基本都能在幾分鐘內(nèi)找到根因。打開一個(gè)多設(shè)備適配項(xiàng)目時(shí)我的建議是先花半小時(shí)把所有的 Row 和 Column 過一遍給每個(gè)有“占滿需求”的子組件打上 Expanded 或 Flexible再跑一遍三種分辨率的模擬器。彈性布局解決了大部分比例問題但真正的最后 10% 還得靠文本截?cái)?、安全區(qū)和輸入法 insets 配合。按這個(gè)順序做OpenHarmony 的界面適配就沒有那么難。