現(xiàn)與優(yōu)化)
1. 一臺手機(jī)裝不下所有需求為什么“畫?!币肍lutter去適配鴻蒙6.01.1 畫師接稿場景下頂部橫幅和快速入口到底承擔(dān)什么功能接稿平臺最怕什么我最怕畫師想找單子的時候頁面卡住或者委托人想進(jìn)約稿頁卻找不到入口。我在做“畫?!边@個畫師接稿平臺時把首頁兩個模塊看得特別重一個是頂部橫幅一個是快速入口。頂部橫幅承載的是運(yùn)營位平臺活動、熱門畫師推薦、約稿規(guī)則變更這些都要在用戶打開App的第一屏展示出來快速入口則是功能分流器發(fā)布約稿、找畫師、作品管理、錢包提現(xiàn)這類高頻操作必須讓用戶一進(jìn)首頁就能點(diǎn)絕對不允許藏在層層菜單后面。這兩個模塊看起來簡單但真放到“畫?!崩镒鼍蜁l(fā)現(xiàn)它們不是普通的輪播圖和宮格菜單。因為目標(biāo)用戶是畫師和約稿委托人訪問時段跨度很大圖片素材又多橫幅如果加載慢或者輪播卡頓用戶直接劃走快速入口如果跳轉(zhuǎn)邏輯不夠清晰畫師會在約稿和作品之間反復(fù)橫跳體驗會很糟。所以我從立項開始就把這兩個模塊當(dāng)成獨(dú)立的小項目來設(shè)計而不只是寫一個Widget糊上去。1.2 跨平臺選型評估原生雙端與Flutter的成本對比“畫?!鄙暇€時團(tuán)隊手里的資源很有限安卓、iOS都要覆蓋而目標(biāo)設(shè)備里又有相當(dāng)一部分是鴻蒙系統(tǒng)。市面上的跨端方案不少但我的選擇是Flutter加HarmonyOS 6.0這個組合。理由很簡單Flutter的渲染引擎足夠統(tǒng)一能讓我用同一套Dart代碼去控制所有端的UI表現(xiàn)而HarmonyOS 6.0對Flutter應(yīng)用提供了完整兼容運(yùn)行環(huán)境只要我在原生工程里做好橋接業(yè)務(wù)層代碼基本不用為不同系統(tǒng)專門維護(hù)。對比原生雙端開發(fā)成本差異很直觀。安卓iOS各寫一套需要兩個人分別維護(hù)橫幅輪播和宮格布局UI細(xì)節(jié)還容易對不齊。用Flutter之后UI描述只有一份平臺適配集中在插件和路由層。當(dāng)時團(tuán)隊里有一個A同學(xué)他專門負(fù)責(zé)原生殼我負(fù)責(zé)Flutter業(yè)務(wù)層整個首頁模塊從設(shè)計到驗收用了不到兩周。這個速度在原生雙端方案里幾乎不可能。當(dāng)然Flutter在鴻蒙上也不是零成本。鴻蒙的返回手勢、安全區(qū)特性和某些系統(tǒng)彈窗仍然需要單獨(dú)適配。這部分我在后面會詳細(xì)展開。如果你也在做類似平臺我建議先不要被“一套代碼走天下”這句話迷惑而是要在選型階段就明確哪些層可以共享哪些層必須開放原生通道。2. 頂部橫幅模塊數(shù)據(jù)協(xié)議、輪播機(jī)制和手勢調(diào)優(yōu)2.1 從后端接口到UI模型橫幅數(shù)據(jù)的協(xié)議設(shè)計頂部橫幅不能只是單純的一個圖片列表。在“畫?!表椖坷餀M幅還承擔(dān)了跳轉(zhuǎn)動作比如點(diǎn)擊橫幅要跳轉(zhuǎn)到某位畫師的主頁或者打開一篇約稿規(guī)則說明。如果接口只返回圖片URL前端就得硬編碼跳轉(zhuǎn)邏輯運(yùn)營換一個運(yùn)營位就得發(fā)版這是絕對不能接受的。所以我給后端提的協(xié)議里每條橫幅必須有四個字段橫幅ID、圖片地址、跳轉(zhuǎn)類型、跳轉(zhuǎn)參數(shù)。跳轉(zhuǎn)類型我用字符串枚舉表示比如artist_home表示畫師主頁article_detail表示文章詳情webview表示內(nèi)置網(wǎng)頁。跳轉(zhuǎn)參數(shù)是一個Map結(jié)構(gòu)可以放畫師ID、文章ID或網(wǎng)頁鏈接。這樣運(yùn)營在后臺配置一條新橫幅時不需要改任何Flutter代碼。在Flutter端我建了一個很薄的模型類做JSON解析時放在fromJson里做兜底。圖片地址如果為空就顯示占位圖避免白屏。跳轉(zhuǎn)類型如果未知就只記錄日志不彈提示防止線上出現(xiàn)無法理解的行為。class BannerItem { final String id; final String imageUrl; final String title; final String linkType; final MapString, dynamic linkParams; const BannerItem({ required this.id, required this.imageUrl, required this.title, this.linkType , this.linkParams const {}, }); factory BannerItem.fromJson(MapString, dynamic json) { return BannerItem( id: json[id] as String? ?? , imageUrl: json[imageUrl] as String? ?? , title: json[title] as String? ?? , linkType: json[linkType] as String? ?? , linkParams: json[linkParams] as MapString, dynamic? ?? const {}, ); } }這段代碼是給團(tuán)隊協(xié)同用的后端同學(xué)只要按這個結(jié)構(gòu)返回前端就不會解析出錯。我會在接口聯(lián)調(diào)前就把這個模型發(fā)到項目群里等后端接口一好直接跑通數(shù)據(jù)流。2.2 輪播實(shí)現(xiàn)PageView、定時器與手勢沖突的處理頂部橫幅輪播很多人第一反應(yīng)是用PageView.builder加一個Timer.periodic。這個方向沒錯但直接這么干會踩到兩個坑。第一個坑是自動輪播和手動滑動沖突用戶正準(zhǔn)備左滑時定時器又切到下一頁視覺上很打架第二個坑是無限循環(huán)的實(shí)現(xiàn)方式如果用PageView官方示例那種“首尾拼接”的方式在快速滑動時會看到首尾直接跳變不夠順滑。我在“畫?!崩锊捎玫姆桨甘亲孭ageView有一個足夠大的itemCount比如10000并把初始頁設(shè)為5000。這樣做的好處是用戶無論怎么滑動都不會觸達(dá)邊界視覺上就是無限循環(huán)。定時器在用戶按住屏幕時會暫停松開后重啟。具體判斷手勢用NotificationListener監(jiān)聽ScrollStartNotification和ScrollEndNotification在滾動結(jié)束時重置定時器。自動輪播有一個很關(guān)鍵的經(jīng)驗不要在PageView的onPageChanged里直接setState去更新指示器那樣會把整棵Widget樹重建一遍導(dǎo)致圖片閃爍。正確做法是把當(dāng)前頁索引存到ValueNotifier里指示器用ValueListenableBuilder單獨(dú)監(jiān)聽這樣只有指示器那一小塊會更新輪播主體不會被無謂重建。Timer? _autoPlayTimer; int _currentPage 5000; final ValueNotifierint _pageIndexNotifier ValueNotifier(5000); void _startAutoPlay() { _autoPlayTimer?.cancel(); _autoPlayTimer Timer.periodic(const Duration(seconds: 4), (timer) { if (_pageController.hasClients !_userInteracting) { final next _pageController.page!.round() 1; _pageController.animateToPage( next, duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ); } }); }關(guān)于時長我試過3秒和5秒最后在4秒上定了稿。對畫師作品來說太短看不清圖太長又讓人等得不耐煩。還有一個小技巧如果當(dāng)前頁面的圖片還沒加載完定時器輪播過去了用戶會看到半張圖。我會在圖片加載完成前先顯示背景色避免白屏閃爍。2.3 圖片加載與緩存策略避免重復(fù)刷新和卡頓橫幅圖片是重災(zāi)區(qū)一張圖動輒幾百K如果每次切頁都重新加載用戶流量和內(nèi)存都會爆。我在項目中直接使用cached_network_image包做圖片緩存緩存算法用默認(rèn)的LRU即可但有一點(diǎn)必須自定義內(nèi)存緩存的大小。鴻蒙設(shè)備上內(nèi)存管理比安卓更嚴(yán)格我把內(nèi)存緩存最大值限制在50MB磁盤緩存放在應(yīng)用私有目錄下。除了緩存容量還要考慮預(yù)加載。使用PageView時我設(shè)置了allowImplicitScrolling: true并且把cacheExtent調(diào)大。這樣相鄰兩頁的圖片會提前加載用戶滑動時幾乎感覺不到等待。實(shí)測在早期版本里如果不做預(yù)加載每次切頁都會出現(xiàn)一個短暫的灰色占位加載完成后圖片突兀地跳出來非常影響觀感。還有一個容易忽略的點(diǎn)橫幅圖片不應(yīng)該直接用普通Image.network。因為Image.network不會在Widget銷毀時主動釋放內(nèi)存還容易出現(xiàn)緩存策略不一致。統(tǒng)一走CachedNetworkImage之后內(nèi)存壓力明顯下降。我也給運(yùn)營提了一個建議圖片不要超過1MB最好是WebP格式這樣居家網(wǎng)絡(luò)環(huán)境下也能較快加載。3. 快速入口模塊宮格布局、配置化路由和防誤觸設(shè)計3.1 入口配置化圖標(biāo)、文案、路由與權(quán)限的映射快速入口的難點(diǎn)不在UI排版而在于它背后的配置關(guān)系。在“畫棧”里不同用戶角色看到的入口不一樣。畫師身份需要“作品管理”“接單記錄”“錢包提現(xiàn)”委托人身份需要“發(fā)布約稿”“找畫師”“我的委托”。如果寫死在代碼里每次權(quán)限調(diào)整都要發(fā)版。我采用的做法是把入口列表也做成接口下發(fā)前端拿到配置后渲染。每個入口在協(xié)議層就是一個QuickEntryItem包含入口ID、顯示名稱、圖標(biāo)URL、跳轉(zhuǎn)類型和權(quán)限標(biāo)識。權(quán)限標(biāo)識用來控制某個角色是否顯示該入口比如role: artist的入口對委托人角色就自動隱藏。這個邏輯放在首頁加載時做不依賴后端過濾因為后端不清楚客戶端的狀態(tài)細(xì)節(jié)。渲染時我建議不要用GridView.count寫死列數(shù)而是根據(jù)最長文案和屏幕寬度動態(tài)算列數(shù)。入口圖標(biāo)我用Icon或Image都可以但需要注意如果入口圖標(biāo)是運(yùn)營在后臺上傳的圖片那必須給Image設(shè)置加載狀態(tài)否則圖標(biāo)加載慢時會顯示空白用戶會以為入口壞了。所以在實(shí)現(xiàn)里每個入口都有一個imageUrl和兜底icon字段圖片加載失敗時自動切換到內(nèi)置圖標(biāo)。class QuickEntryItem { final String id; final String title; final String iconUrl; final String linkType; final String linkValue; final String role; const QuickEntryItem({ required this.id, required this.title, this.iconUrl , this.linkType , this.linkValue , this.role , }); factory QuickEntryItem.fromJson(MapString, dynamic json) { ... } }這樣設(shè)計之后新增一個入口只需要改后臺配置前端無需發(fā)版。對“畫棧”這種快速迭代的平臺來說這種靈活度很重要。3.2 布局細(xì)節(jié)從屏幕寬度到system文字縮放的約束快速入口的布局其實(shí)比想象中容易出問題。常見做法是四個入口一行一屏顯示兩行。但鴻蒙和安卓都有系統(tǒng)字體縮放功能如果用戶把字體調(diào)成超大入口文字就會溢出到圖標(biāo)上布局直接崩掉。我在項目里做的處理是入口區(qū)域不直接使用TextScaleFactor跟隨系統(tǒng)而是手動限制最大字號。每個入口卡片的寬度使用固定比例比如屏幕寬度的25%文字最多顯示一行超出部分省略號截斷??ㄆ叨炔还潭ǘ歉鶕?jù)內(nèi)容自適應(yīng)最小高度設(shè)為64邏輯像素避免某些高度下圖標(biāo)和文字重疊。另一個細(xì)節(jié)是點(diǎn)擊區(qū)域??焖偃肟诘膱D標(biāo)和文字之間點(diǎn)擊熱區(qū)要盡量大但又不允許誤觸。如果整個卡片都用GestureDetector用戶在圖標(biāo)的邊緣點(diǎn)時可能觸發(fā)相鄰入口。我改用InkWell包裹并通過Behavior: HitTestBehavior.opaque來確保點(diǎn)擊命中的是當(dāng)前卡片區(qū)域。同時每個入口卡片之間留出12個邏輯像素的間距避免手指寬的用戶一次按到兩個入口。3.3 防誤觸和重復(fù)跳轉(zhuǎn)事件吸收與路由去重快速入口有一個讓我抓狂的Bug連續(xù)點(diǎn)擊同一入口會打開兩個相同的頁面。尤其“發(fā)布約稿”這種頁面如果用戶手抖雙擊就會出現(xiàn)兩個編輯頁堆疊保存時還可能覆蓋數(shù)據(jù)。我在分析后發(fā)現(xiàn)問題不在路由本身而在于Flutter的InkWell在快速點(diǎn)擊時會觸發(fā)多次onTap事件而路由跳轉(zhuǎn)又是異步的導(dǎo)致第一次跳轉(zhuǎn)還沒完成第二次就開始了。解決辦法是加一個全局的導(dǎo)航守衛(wèi)。我在路由跳轉(zhuǎn)前維護(hù)一個_lastTapTime如果兩次點(diǎn)擊間隔小于500毫秒直接忽略。另外在頁面級維護(hù)一個“是否已經(jīng)打開”的標(biāo)志比如利用RouteAware當(dāng)前頁面已經(jīng)在前臺時不允許再跳轉(zhuǎn)。這套防抖邏輯寫完后重復(fù)跳轉(zhuǎn)的問題徹底消失。防誤觸還不止重復(fù)跳轉(zhuǎn)一種情況。入口之間點(diǎn)擊區(qū)域相鄰有時候用戶想點(diǎn)“找畫師”結(jié)果點(diǎn)到了旁邊的“作品管理”。我用的是“點(diǎn)擊反饋延遲”方案按下時先顯示按壓效果等松手后如果手指還在當(dāng)前卡片內(nèi)才觸發(fā)跳轉(zhuǎn)。這樣即使手指滑動了一點(diǎn)點(diǎn)也不會誤觸發(fā)。4. 鴻蒙6.0適配防坑安全區(qū)、圖片緩存與啟動速度的實(shí)測處理4.1 狀態(tài)欄、劉海屏和底部手勢區(qū)的安全區(qū)問題把Flutter應(yīng)用跑到HarmonyOS 6.0上第一個要認(rèn)真處理的就是安全區(qū)。鴻蒙手機(jī)頂部有狀態(tài)欄部分機(jī)型還有挖孔或劉海底部有手勢條。如果直接使用SafeArea包裹整個頁面頂部和底部雖然安全了但會出現(xiàn)難看的白邊背景色無法延伸到狀態(tài)欄特別不協(xié)調(diào)。我在“畫?!钡氖醉摬捎昧税氤两皆O(shè)計背景是品牌色內(nèi)容區(qū)從安全區(qū)邊緣開始布局。具體做法是先獲取MediaQuery.of(context).padding.top再把橫幅和快速入口的容器設(shè)為從屏幕頂部開始并給其內(nèi)部增加對應(yīng)的頂部內(nèi)邊距。底部也類似給快速入口區(qū)域留出比系統(tǒng)手勢條略高的邊距保證手勢條滑動時不會碰到按鈕。這里有一個經(jīng)驗HarmonyOS 6.0的底部手勢條檢測并不完全等同于安卓的View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR。Flutter層的MediaQuery在某些系統(tǒng)版本上返回的padding.bottom會偏小導(dǎo)致頁面內(nèi)容和手勢條重疊。我后來在原生殼工程里拿到真實(shí)的手勢條高度通過MethodChannel傳給Flutter側(cè)從而修正偏移。如果你的團(tuán)隊有原生開發(fā)同學(xué)這個建議可以直接落地。4.2 路由跳轉(zhuǎn)和頁面生命周期與原生側(cè)手勢返回的約定Flutter應(yīng)用在鴻蒙上的路由架構(gòu)和純安卓有些區(qū)別。鴻蒙系統(tǒng)的返回手勢有兩種處理如果頁面是用Flutter自己的Navigator管理那么從屏幕邊緣右滑時Flutter能感知到并觸發(fā)返回但如果頁面里嵌入了原生View或者有原生插件手勢沖突就會出現(xiàn)。我在“畫棧”的快速入口里凡是跳轉(zhuǎn)到原生頁面比如打開系統(tǒng)分享的場景都會先通過RouteAware把當(dāng)前路由的生命周期記錄下來。原生頁面返回時Flutter側(cè)會收到resume事件并刷新入口狀態(tài)。如果這一步不做用戶從原生頁返回后快速入口的上報埋點(diǎn)會丟失點(diǎn)擊統(tǒng)計不準(zhǔn)確運(yùn)營就不知道哪個入口最受歡迎。另外導(dǎo)航守衛(wèi)不能只做防抖還要處理頁面壓棧關(guān)系。我用的是命名路由onGenerateRoute統(tǒng)一處理每個入口的跳轉(zhuǎn)目標(biāo)都聲明在路由表里避免直接傳遞Widget對象。這樣在鴻蒙上做頁面級內(nèi)存回收時路由棧能被系統(tǒng)正確管理減少頁面泄漏。4.3 啟動速度與滾動性能幀率分析和削減首幀工作量頂部橫幅和快速入口雖然只是幾個小組件但它們出現(xiàn)在首頁第一屏直接影響首幀渲染時間。如果等待這兩個模塊的網(wǎng)絡(luò)接口都返回后才渲染啟動會明顯變慢。我在項目中做了一個分層加載策略先渲染頁面骨架再加載快速入口最后加載橫幅。頁面骨架是一個輕量級的占位視圖用FutureBuilder控制在獲取到配置前顯示。這樣用戶打開App后第一時間看到的是品牌色背景和入口輪廓雖然圖片還沒加載但至少頁面不白屏。等到接口響應(yīng)后再填充橫幅和入口。通過這種方式首幀時間從原來的1.8秒降到了1.1秒左右。滾動性能方面我監(jiān)控了橫幅的FPS。早期版本里每張橫幅都是全屏寬的高清圖滾動時會掉到30幀。后來我限制了圖片尺寸不再使用原圖而是讓后端返回按設(shè)備寬度切割的縮略圖并設(shè)置gaplessPlayback: true滾動終于穩(wěn)定在60幀。這里我強(qiáng)烈建議橫幅圖片一定要用服務(wù)端裁剪而不是客戶端等比縮放。5. 兩個讓我印象深刻的Bug排查鏈路輪播閃爍與快速入口重復(fù)跳轉(zhuǎn)5.1 輪播圖片閃爍問題從現(xiàn)象反推到Widget重建根因輪播閃爍這個問題我排查了一個下午?,F(xiàn)象是自動輪播到下一張時圖片會閃一下白色接著才顯示完整圖片。一開始以為是網(wǎng)絡(luò)圖沒緩存但后來發(fā)現(xiàn)即使圖片已經(jīng)在緩存里切換時依然會閃。于是我懷疑是PageView在重建頁面導(dǎo)致Image重新加載。后來我打印了BannerItem的hashCode發(fā)現(xiàn)每次輪播時頁面里的橫幅列表對象都會重新創(chuàng)建一次。原因出在FutureBuilder上因為父級每次setState時都會重建Future然后FutureBuilder進(jìn)入waiting狀態(tài)導(dǎo)致子頁面全部重建。解決辦法是把拿到的橫幅列表緩存到狀態(tài)管理對象里而不是每次都從Future獲取。這樣PageView的itemBuilder傳入的模型引用保持穩(wěn)定圖片ImageCache也能命中。這個坑也讓我意識到在寫輪播時一定要區(qū)分“頁面重建”和“數(shù)據(jù)刷新”。輪播的數(shù)據(jù)只在接口返回后更新日常的自動翻頁絕不能觸發(fā)數(shù)據(jù)層刷新。如果你的輪播也有類似閃爍先檢查父級Widget是否有不穩(wěn)定的狀態(tài)。5.2 快速入口連續(xù)點(diǎn)擊重復(fù)跳轉(zhuǎn)命中InkWell的事件風(fēng)暴重復(fù)跳轉(zhuǎn)的問題前面提到了但排查鏈路值得展開。最開始我懷疑是Navigator.push反復(fù)執(zhí)行于是試著在onTap里加print結(jié)果發(fā)現(xiàn)一次手指點(diǎn)擊就觸發(fā)了三次onTap這很不正常。進(jìn)一步檢查原來問題出在我用了Semantics相關(guān)配置某個無障礙手勢被同時解釋為兩次正常點(diǎn)擊加上InkWell和父級GestureDetector同時響應(yīng)導(dǎo)致事件風(fēng)暴。處理方法是給QuickEntryItem卡片只保留一個手勢識別者去掉外層無必要的GestureDetector。同時配合導(dǎo)航守衛(wèi)的500毫秒防抖問題就穩(wěn)定了。我還把這個邏輯抽成了通用工具類DebouncedNavigator不只用于快速入口橫幅點(diǎn)擊跳轉(zhuǎn)也復(fù)用了它。這個教訓(xùn)總結(jié)下來就是事件處理要遵循“一個視圖只認(rèn)一種手勢”的原則。尤其是在鴻蒙系統(tǒng)上不同版本對手勢事件的分發(fā)策略有細(xì)微差異過度堆疊手勢判斷很容易出現(xiàn)奇怪的重復(fù)觸發(fā)。6. 我最后想分享的幾個細(xì)節(jié)做完這兩個模塊我最大的體會是頂部橫幅和快速入口雖然只是首頁的兩個“插件”但它們把產(chǎn)品運(yùn)營、數(shù)據(jù)上報、圖片性能、路由管理全部串在了一起。如果你也在做跨端應(yīng)用我建議把這兩個模塊當(dāng)成獨(dú)立的“微前端”來設(shè)計每個模塊有自己的數(shù)據(jù)模型、狀態(tài)管理和路由配置這樣后面無論換系統(tǒng)還是加功能都能快速調(diào)整。另外HarmonyOS 6.0的適配不能靠猜。我建議在拿到真機(jī)之前先在Android和模擬器上把UI邏輯跑通然后再用鴻蒙真機(jī)驗證安全區(qū)和手勢條。對于圖片緩存一定要在低端機(jī)上測試因為低端機(jī)的內(nèi)存更容易爆掉。每個應(yīng)用的性能瓶頸不同但輪播閃爍和點(diǎn)擊重復(fù)跳轉(zhuǎn)這兩個問題大概率會出現(xiàn)在Flutter開發(fā)的首頁上先自查總比用戶反饋好。