前言
這篇文章是我一直以來很想寫的一篇文章,終於下定決心動筆了。
寫Flutter的小夥伴可能都感受到了:掘金的一些熱門的Flutter文章下,知乎的一些Flutter的話題下或者一些論壇裡面,噴Flutter套娃地獄總是永不過時的一個話題。
如果你不服氣,上去辯駁倆下:“巢狀是你程式碼習慣問題,你看我,抬手一個Row,反手一個Column,在children中把widget一提,層次分明,年輕人望你耗子尾汁,莫要瞎帶節奏”;然後你可能就被一群人噴成狗,大意了,這帖子沒同一陣營的小夥伴,噴不過,閃了閃了;一般被噴後,不是身經百被噴,都需要一段時間來平復心情。。。
所以,終於我下定決心把這篇文章肝出來,如果你認真看完,你可能會發現:巢狀什麼的都是浮雲,從此你的頁面程式碼將變的超級好維護,互動邏輯入口,也變得層次分明。
全篇文章,絕無教大家做事之意,這是在專案中摸爬滾打,被坑出的不得不如此規範的一種行為。
看本文之前,大家可以先了解下這篇文章:Flutter 對狀態管理的認知與思考
- 本文主要的思想,是簡化繁瑣的Action層,歸類重要的業務入口
準備
這篇文章能幫你改善什麼問題?
頁面層的widget瘋狂套娃幾千行,後期維護,心態崩了等問題
- 套娃不劃分頁面,後期需求大變,讓你大改頁面細節甚至結構,那將是非常難受的一件事
邏輯互動事件入口,混雜在widget,難以尋找問題
- 如果你在頁面層瘋狂套娃,你會發現,就算用了provider,bloc中的cubit,getx之類,你想找到邏輯互動入口,也是一件很累的事情,改樣式那就更方了。。。
- 這裡再嗶嗶一下,這些框架作者肯定是發現了這種情況,所以bloc才搞出了event層,redux搞出了action層,來統一管理事件及其事件入口。
- 頁面結構充斥大量細節,結構調整起來困難
上面關於頁面層的這些問題,如果多人協同開發一個大型專案,程式碼不規範的話,大機率都是會遇到的(改別人寫的模組...);後期改需求 ,真的是一種折磨,有種碼海找針的感覺。
如果改你自己寫的模組,那可能還會好點,畢竟你還有點印象,整個模組的大概思路,還知道怎麼改。如果是改別人寫的模組,你就需要在大量widget海中,去揣摩別人寫這些widget的意圖,結構一下子也不能理清,十分痛苦,有可能邊改邊罵罵咧咧的。。。
Demo效果
在構思文章的時候,就在想演示的Demo頁面必定不能過於簡單,一個簡單的Demo頁面,怎麼能演示出套娃地獄的改善效果呢?思考了很久,想尋找一個合適demo頁面,週末時在聽喜馬拉雅裡面的盜墓小說,看了看發現頁面,發現整體樣式不錯,咱就仿一個吧!而且整體的頁面複雜度,也足夠來演示了!
喜馬拉雅的這個PC頁面Demo,寫起來真的花費了不少時間,希望能對大家有所幫助吧。
地址
Web:仿喜馬拉雅頁面
- web無法強制設定視窗大小,可能需要你調整下web視窗的寬度,以達到最佳效果
Windows:Windows平臺安裝包
- 如果你的電腦開啟了125%的
縮放與佈局
,請打首頁的開啟縮放
按鈕
- 如果你的電腦開啟了125%的
- 專案地址:flutter_use
說明
程式碼已經發布到Github上,web端也已經部署好了,因為使用的CanvasKit模式打包的,首次載入可能比較慢,多等一會,因為Web端部署在Github上,訪問的話,要確保你的網路能訪問Github。
關於Widows安裝包
- Window筆記本高分屏一般會開啟125%的縮放,這時候,存在一個坑比的問題,開啟縮放的時候,Flutter的佈局都會相應的縮放,但是坑比的是,整體的視窗並不會縮放,導致內容會積壓整體的視窗,這個問題我也在幾臺電腦上,調了好久才發現的。
- 解決辦法,寫了個手動開啟適配的功能。
- 關於
開啟縮放
的按鈕功能,只支援放大125%視窗功能,其它的也不用折騰了,我發現window_size初始化後,第一次設定完視窗尺寸後;然後,再設定視窗時,往大了設定有效,往小了回撥會無效,奇怪。。。
效果對比
來對比下仿製的效果吧,有個六七成相似,很多Icon和圖片實在找不到相似,,,這裡demo只提供一個樣式演示,功能別想了,這不是一朝一夕,一個人能搞出的。。。
照片都是從喜馬拉雅web端上搞下來的,資料一直在變,相應欄目的資料有對不上,但是整體樣式大致還是差不多。
其中Banner模組是區別最大的一塊,用的三方庫只能支援搞成這樣,各位靚仔將就著看看吧。
- 原版的喜馬拉雅PC頁面
- 仿製的喜馬拉雅頁面
總結
上面倆組圖片,細節方面對比基本慘不忍睹,但是整體架構上還是比較相似。
建議各位彥祖,下載下window安裝包,安裝體驗下;MacOS的於晏們,你們可以看看web展示效果。
咱們馬上來看看怎麼搞規範程式碼吧!複雜的模組,讓你的程式碼也能高度可維護!
分析
Android的業務自定義View
- 在Android裡面有個頁面分模組的開發思想,將整個頁面劃分成幾個業務的自定義View,我們只需要關注傳入資料來源,和對應業務View互動的回撥事件;資料來源和互動事件是重點需要關注,其它的都不是我們需要關心的,不需要關注的細節封裝在內部即可
- 然後主頁面裡面,組合下這些業務view就OK了;徹底拋棄include坑比做法,include讓xml也耦合了,如果改動了一個被多處引用的xml,可能會引發的一些影響,大家心裡可以揣摩揣摩
- 上面的思想:明顯是外觀模式(門面模式)的思想。。。
Flutter的Widget
- 然後再結合Flutter中那些眾多的系統widget,系統那些Widget基本都屬於功能性的Widget,需要定義巨量的欄位傳值
- 這樣的好處,就是能夠非常顆粒的去控制需要的欄位,再配合一些定義的回到函式,就能起到:資料來源和互動回撥的完美組合。
結合上面的業務View和一切皆Widget的思路,我們可以得出一個結論:搞業務Widget,然後再進行組合!
當然,咱們在這裡得出了一個不是結論的結論,一般來說,這種操作是咱們基本素養,但是具體的操作細節上,還是有很多需要注意的:
- 業務Widget,也需要劃分模組
- Column,Row之類有著天然結構,怎麼去利用
- 旁枝末節的Widget細節,怎麼去封裝
主模組封裝
上面咱們一通分析猛如虎後,得出一個結論:搞業務Widget!
關於業務Widget的封裝細節,這裡說明下:
資料來源儘量只使用一個,不要使用過多欄位去劃分
- 解釋下,因為我們這是業務性widget,並不是功能性widget,過渡的細分欄位輸入,會導致你封裝的widget過長,業務Widget很多時候,只會在你這個模組,其它模組一般都很少用的,沒必要去過度的細分欄位,開發多了你就會發現,你封裝的那些業務Widget,百分之95的機率,只會在你自己寫的那個頁面吃灰一輩紙。。。
- 如果是比較通用的widget,那就可以細分欄位了或者使用中間實體都OK! 通用的模組開發,關於資料來源輸入,就需要考慮一些比較通用的資料格式,例如只需要一個list資料,就不要搞一個實體,只需要一個欄位,就不需要搞一個list等等。。。
互動事件,必須使用回撥函式,暴露出來
- 關於互動事件,這裡必須要暴露出來,給業務層或者邏輯層去處理
- 一般來說,使用者進入該頁面,點選或滑動頁面,就是業務事件產生的時候了,這是必須暴露出來的,切記切記。
主模組的結構
這裡使用了一點Getx知識,如果你不瞭解,可參考:Flutter GetX使用---簡潔的魅力!
主模組程式碼:按照下面的封裝,基本是把View層和Action層做了一個結合了
- 所有業務Widget的入口,可快速定位到需要修改的業務Widget
- 所有的事件互動入口,一眼可見,這樣能快速定位相應的業務
class HimalayaPage extends StatelessWidget {
final logic = Get.put(HimalayaLogic());
final state = Get.find<HimalayaLogic>().state;
@override
Widget build(BuildContext context) {
return himalayaBuildBg(children: [
//頂部:左邊側邊導航欄 + 右邊資訊流
himalayaBuildTopBg(children: [
//左邊導航欄
HimalayaLeftNavigation(
data: state,
//導航欄item回撥
onTap: (HimalayaSubItemInfo item) => logic.navigationItem(item),
),
//右邊資訊流
himalayaBuildInfoListBg(children: [
//頂部搜尋框及其一些個人資訊設定按鈕
HimalayaPersonalInfo(
//搜尋框輸入監聽
onChanged: (String msg) => logic.onSearch(msg),
//左箭頭
onLeftArrow: () => logic.dealLeftArrow(),
//右箭頭
onRightArrow: () => logic.dealRightArrow(),
//重新整理按鈕
onRefresh: () => logic.onRefreshData(),
//皮膚按鈕
onSkin: () => logic.switchSkin(),
//設定按鈕
onSetting: () => logic.onSetting(),
),
//右側資訊流 - 可滑動部分
himalayaBuildScrollInfoListBg(children: [
//輪播圖
HimalayaBanner(
data: state.bannerList,
//具體banner的監聽
onTap: (int index) => logic.clickBanner(index),
),
//猜你喜歡
HimalayaGuess(
data: state.guessList,
//換一批
onChange: () => logic.guessChange(),
//猜你喜歡具體卡片
onGuess: (HimalayaSubItemInfo item) => logic.guessDetail(item),
),
//最新精選
HimalayaNewest(
data: state,
//分類標題
onSortTitle: (item) => logic.sortTitle(item),
//具體精選卡片
onNewest: (HimalayaSubItemInfo item) => logic.onNewest(item),
),
//熱門主播
HimalayaAnchor(
data: state.anchorList,
onAnchor: (HimalayaSubItemInfo item) => logic.hotAnchor(item),
),
//各類榜單
HimalayaRankList(
data: state.rankList,
//標題
onTitle: (String title) => logic.rankTitle(title),
//榜單上具體item
onItem: (HimalayaSubItemInfo item) => logic.rankItem(item),
),
]),
]),
]),
//底部:音訊播放控制檯
HimalayaAudioConsole(
data: state.audioPlayInfo,
//左切換
onLeftArrow: () => logic.onLeftArrow(),
//播放
onPlay: () => logic.onPlay(),
//右切換
onRightArrow: () => logic.onRightArrow(),
//喜歡
onLove: () => logic.onLove(),
//播放模式
onPlayModel: () => logic.onPlayModel(),
//封面
onCover: () => logic.onCover(),
//進度
onProgress: () => logic.onProgress(),
//音量
onVolume: () => logic.onVolume(),
//標題
onSubtitle: () => logic.onSubtitle(),
//倍速
onSpeed: () => logic.onSpeed(),
//定時
onTiming: () => logic.onTiming(),
//目錄
onCatalog: () => logic.onCatalog(),
),
]);
}
}
經過上面的一通封裝組合後,大家摸著良心說說:
- 還死亡巢狀嗎?
- 還俄羅斯套娃嗎?
- 看著還恐怖嗎?
別噴套娃了,外觀模式的思想稍稍這麼一用,套娃直接GG
設計模式,yyds!
細節分析
一般來說,一個頁面整體基本上是橫向(Row)或者縱向(Column)的結構
咱們仿造的喜馬拉雅模組也是屬於縱向結構:上下倆大模組
上模組:導航欄 + 資訊流 => 又分左右模組
- 左模組:左邊的側面導航欄 => 很明顯的縱向佈局
- 右模組:資訊流 => 這就是簡單的縱向結構,從上到下了
- 下模組:音訊播放欄 => 完全就是橫向佈局了
透過上面的說明,很明顯,Row和Column中children屬性才是我們所關注的,其它的細節描述封裝起來即可
主體細節封裝
主模組的很多主體細節,是完全可以封裝起來的,新建一個(模組名_function)檔案
himalaya_function.dart:主體部分有很多無需關注的細節,統一放在這個模組
- 對外,只需要暴露一些必須的引數
- 請勿將這些無關的細節寫在主模組中,會干擾到我們需要關注的資訊
- 這些主體樣式寫完後,基本就很少去修改了
///喜馬拉雅整體外層佈局設定
Widget himalayaBuildBg({required List<Widget> children}) {
return Scaffold(
backgroundColor: Colors.white,
body: Column(children: children),
);
}
///播放控制欄上面的外層佈局設定
Widget himalayaBuildTopBg({required List<Widget> children}) {
return Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
///頂部右側資訊流外層佈局設定
Widget himalayaBuildInfoListBg({required List<Widget> children}) {
return Expanded(
child: Column(children: children),
);
}
///頂部右側資訊流外層佈局設定 - 可滑動部分
Widget himalayaBuildScrollInfoListBg({required List<Widget> children}) {
return Expanded(
child: CustomSingleChildScrollView(
child: Container(
width: 860.dp,
child: Column(children: children),
),
),
);
}
業務Widget封裝
關於業務Widget封裝,是核心所在,這個非常重要⭐
幾個要點
- 儘量只暴露一個資料來源(非通用業務Widget)
- 所有的事件互動必須暴露出來
- 主體細節封裝起來
- children中的widget全部提成方法
children中封裝
先來看看第一種情況,最常見的情況,children的widget,從上到下排列下來,非列表類資料
- 來看看這個頂部一些功能按鈕的佈局,這塊涉及到很多事件互動,所以單獨提成了一個業務Widget
實現程式碼:關於業務Widget,這是基石,規範寫好後,後期修改,異常簡單
- 結合上面的效果圖,再結合下面的程式碼,大家應該一眼看出來,就知道是哪個widget方法,對應介面上的哪個控制元件;如果你想修改哪個控制元件樣式,直接點進對應的widget方法裡修改即可
- children裡面的每個widget方法上面,請一定一定記得寫上註釋,因為此處才是業務Widget最主要的入口,具體的widget方法寫不寫註釋無所謂了
///搜尋框 個人資訊 設定等按鈕
class HimalayaPersonalInfo extends StatelessWidget {
HimalayaPersonalInfo({
Key? key,
required this.onRefresh,
required this.onLeftArrow,
required this.onRightArrow,
required this.onSetting,
required this.onSkin,
required this.onChanged,
}) : super(key: key);
.............
@override
Widget build(BuildContext context) {
return _buildBg(children: [
//左圖示
_buildLeftArrow(),
//右圖示
_buildRightArrow(),
//重新整理圖示
_buildRefresh(),
//搜尋框
_buildSearch(),
//頭像
_buildHeadImg(),
//皮膚
_buildSkin(),
//設定
_buildSetting(),
]);
}
..........
}
來看下其中的
_buildBg
方法- 可以發現
_buildBg
主體的這些細節描述,真的是無關緊要的程式碼,這個寫完後,基本上,後面都很少去改,所以把它提取出來後,放在牆角吃灰就行了
- 可以發現
///搜尋框 個人資訊 設定等按鈕
class HimalayaPersonalInfo extends StatelessWidget {
........
Widget _buildBg({required List<Widget> children}) {
return Container(
margin: EdgeInsets.symmetric(vertical: 10.dp, horizontal: 18.dp),
width: 800.dp,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: children,
),
);
}
}
關於方法提取
- 選中你需要提取的Widget程式碼
- 開啟 Flutter Outline 選擇
右箭頭
圖片
- 填上方法名後,就能自動生成一個widget方法
- 如果你提取的Widget塊中,還含有一些資料,自動生成的方法都會帶上相應引數,非常方便
單層列表樣式封裝
類列表樣式的封裝也是比較關鍵的,直接從頭莽尾式的提取是不行,這邊有一絲調整
這裡就以猜你喜歡
模組舉例
- 猜你喜歡模組
程式碼分析:總體是Column佈局,分上下倆模組
- 上模組使用Row搞定即可
- 下模組是四個卡片,這邊是直接用的寫死List資料來源
///猜你喜歡
class HimalayaGuess extends StatelessWidget {
HimalayaGuess({
Key? key,
required this.data,
required this.onChange,
required this.onGuess,
}) : super(key: key);
..........
@override
Widget build(BuildContext context) {
return _buildBg(children: [
//標題 + 換一批
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
//標題
_buildTitle(),
//換一批
_buildGuessChange()
]),
//顯示具體資訊流
_buildItemBg(itemBuilder: (item) {
return [
//圖片卡片
_buildPicCard(item),
//文字描述
Text(item.title, style: TextStyle(fontSize: 15.sp)),
//子標題
_buildSubTitle(item),
];
})
]);
}
..........
}
上述children程式碼,整體上還是比較清晰,有點迷糊的,可能就是
_buildItemBg
,來看看其中程式碼- 此方法對面暴露了一個
itemBuilder
引數,這其實是一個回撥方法 - 因為列表類樣式,必須要遍歷整個列表資料,然後,需要把列表遍歷的具體資料,反向傳給Widget
- 所以必須使用回撥方法反傳資料
- 此方法對面暴露了一個
///猜你喜歡
class HimalayaGuess extends StatelessWidget {
...............
Widget _buildItemBg({
required List<Widget> Function(HimalayaSubItemInfo item) itemBuilder,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(data.length, (index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemBuilder(data[index]),
);
}),
);
}
}
雙層列表樣式封裝
關於雙層列表資料來源(List的每個具體資料來源,又含有List)又該怎麼封裝呢?
倆層List資料來源封裝是比較麻煩,這邊以側邊欄舉例
- 整個佈局是一個Column:標題 + 欄目(List資料控制)
欄目
- 可劃分具體的Item
- Item:標題 + 欄目(List資料控制)
程式碼實現
- 上面的佈局整體是由資料來源驅動頁面,資料能控制頁面item生成
///資料來源:側邊導航欄目初始資料,簡化了下,資料來源太長了
///該資料來源都放在state層維護,此處放在這裡,讓大家有個對比
leftItemList = [
HimalayaItemInfo(title: '推薦', subItemList: [
HimalayaSubItemInfo(
title: '發現',
icon: CupertinoIcons.compass,
tag: TagHimalayaConfig.find,
isSelected: true,
),
..............
]),
HimalayaItemInfo(title: '我聽', subItemList: [
HimalayaSubItemInfo(
title: '我的訂閱',
icon: Icons.star_border,
tag: TagHimalayaConfig.subscription,
),
.........
]),
HimalayaItemInfo(title: '我建立的聽單', subItemList: [
HimalayaSubItemInfo(
title: '我喜歡的聲音',
icon: Icons.favorite_border,
tag: TagHimalayaConfig.sound,
),
............
]),
];
///左邊導航欄
class HimalayaLeftNavigation extends StatelessWidget {
HimalayaLeftNavigation({
Key? key,
required this.data,
required this.onTap,
}) : super(key: key);
........
@override
Widget build(BuildContext context) {
return _buildBg(children: [
//喜馬拉雅logo圖示
_buildLogo(),
//遍歷倆層迴圈:不同item欄目 - 可點選,可滑動
//第一層:標題 + 子item列表
//第二層:子item詳細布局
_buildItemListBg(itemBuilder: (item) {
return [
//最外層item - 大標題
_buildTitle(item.title),
//子欄目 - 列表
_buildSubItemListBg(item, subBuilder: (subItem) {
return [
//選中紅色長方形條塊
_buildRedTag(subItem),
//圖示
_buildItemIcon(subItem),
//描述
_buildItemDesc(subItem),
];
})
];
}),
]);
}
..........
}
第一層:來看下第一層
_buildItemListBg
方法- 這玩意不得不套了,需要的屬性太多了:滾動,捲軸等
- 這玩意要是不提出來,從上往下套,那簡直就是毒瘤。。。
class HimalayaLeftNavigation extends StatelessWidget {
..........
Widget _buildItemListBg({
required List<Widget> Function(HimalayaItemInfo item) itemBuilder,
}) {
return Expanded(
child: Scrollbar(
child: CustomSingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(data.leftItemList.length, (index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemBuilder(data.leftItemList[index]),
);
}),
),
),
),
);
}
}
第二層
- 這裡面必須需要第一層遍歷的具體資料來源,所以必須增加一個輸入引數
- 這裡就是常規提取,需要注意的就是傳入的資料來源
class HimalayaLeftNavigation extends StatelessWidget {
..........
Widget _buildSubItemListBg(
HimalayaItemInfo item, {
required List<Widget> Function(HimalayaSubItemInfo item) subBuilder,
}) {
return Column(
children: List.generate(item.subItemList.length, (index) {
return InkWell(
onTap: () => onTap(item.subItemList[index]),
child: Container(
padding: EdgeInsets.symmetric(vertical: 9.dp),
child: Row(children: subBuilder(item.subItemList[index])),
),
);
}),
);
}
}
總結
經過上面的一通操作,業務Widget立馬變的清爽N倍
大家在寫Flutter的時候,應該能明顯的感覺到,寫頁面擁有高度的自由,樣式、頁面結構及其邏輯全都能耦合在一起。
既然我們還達不到,無招勝有招的水平;那麼下筆之前還是要有點章法的好,所以在實際開發中,要注意自己程式碼規範啊。。。
假設一種情況
- 你開發完一個模組
- 過了幾月之後,需求調整,你要去改這個模組
- 看到幾千行的套娃頁面程式碼,然後一邊改一邊罵罵咧咧,開噴:這是哪個睿智的人寫的!!!
- 最後開啟檔案的git註釋(annotate)記錄,結束上面寫滿了你的名字
- 那豈不是很尷尬。。。
題外話
說一點題外話
實際上寫html也是無限套娃,不同的是,它從根本上做到的樣式結構分離,控制元件的細節描述,全部交給了css去做,所以頁面整體看上去還是滿清爽的:
但是有一點讓我很蛋筒,寫小程式的時候,檢視具體控制元件的描述樣式,需要跨檔案去找
- uniapp則是直接把這些東西放在一個檔案裡(19年寫的時候是這樣的,不知道現在有沒有改),算是一種改善,查詢起來方便,但是單個檔案程式碼量有點爆炸
- 樣式因為是交給css去處理,層級描述也放在css中,有時候看程式碼看的有點懵逼(是我太菜了)
Flutter直接從根本上樣式結構不分離,結構上直接從上往上下一套到底
- 優點:修改樣式簡單(方便定位);結構清晰(從上往下看就行了)
- 缺點:程式碼閱讀,觀感爆炸;不做模組劃分,後期程式碼維護困難
所以,哪裡有十全十美的框架,總是有舍有得。。。
新的事物發展,必然會迎來相應的阻力
這裡假設一種場景:
- 你已經寫了倆三年Flutter了,各種控制元件,框架玩的牛的飛起
- 然後,你聽說:又來了一種神奇的,跨時代的前端框架,甚至能無縫呼叫所有平臺的底層硬體api,omg,反正就是各種6
- 然後你看到,關於這種跨時代框架的文章,在各個技術論壇中,瘋狂湧現
- 此時,你心中會不會有絲絲異樣,心想:雜家,這幾年Flutter白寫了?又得去學這個新框架了?我踏馬豈不是又變成萌新了!又要天天去群裡抱大佬大腿了!
- 然後你看到那一片片熱點文章,文章下滿是捧上天的評論,,,
- 此時,你的心中會不會有絲波瀾,想當一當這技術界的清醒者,情不自禁吟誦:眾人皆醉我獨醒.....
- 然後,拿起鍵盤,化身一個大噴子,以一敵百,不落下風
- 一瞬間,讓你覺得:這個論壇,現在叫lbw論壇!我就是這論壇的王!
角色互換
其實,對於很多言論,我們沒必要在意;角色互換,說不定,對方此刻的行為,就是我們自己以後可能會做的事。
其實,我們都是打工人,又何必撕來撕去呢?
最後
文中DEMO地址:flutter_use
系列文章
透過上面一些程式碼規範操作後,再配合上GetX的狀態管理,相信一般的專案,你都能hold的住了
加油,我們都是這條街,最靚的仔
- 狀態管理:Flutter GetX使用—簡潔的魅力!
- 一種優雅dialog解決方案:這一次,解決Flutter Dialog的各種痛點!