Flutter 改善套娃地獄問題(仿喜馬拉雅PC頁面舉例)

小呆呆666發表於2021-03-15

前言

這篇文章是我一直以來很想寫的一篇文章,終於下定決心動筆了。

寫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%的縮放與佈局,請打首頁的開啟縮放按鈕
  • 專案地址:flutter_use
說明

程式碼已經發布到Github上,web端也已經部署好了,因為使用的CanvasKit模式打包的,首次載入可能比較慢,多等一會,因為Web端部署在Github上,訪問的話,要確保你的網路能訪問Github。

  • 關於Widows安裝包

    • Window筆記本高分屏一般會開啟125%的縮放,這時候,存在一個坑比的問題,開啟縮放的時候,Flutter的佈局都會相應的縮放,但是坑比的是,整體的視窗並不會縮放,導致內容會積壓整體的視窗,這個問題我也在幾臺電腦上,調了好久才發現的。
    • 解決辦法,寫了個手動開啟適配的功能。
    • 關於開啟縮放的按鈕功能,只支援放大125%視窗功能,其它的也不用折騰了,我發現window_size初始化後,第一次設定完視窗尺寸後;然後,再設定視窗時,往大了設定有效,往小了回撥會無效,奇怪。。。
效果對比

來對比下仿製的效果吧,有個六七成相似,很多Icon和圖片實在找不到相似,,,這裡demo只提供一個樣式演示,功能別想了,這不是一朝一夕,一個人能搞出的。。。

照片都是從喜馬拉雅web端上搞下來的,資料一直在變,相應欄目的資料有對不上,但是整體樣式大致還是差不多。

其中Banner模組是區別最大的一塊,用的三方庫只能支援搞成這樣,各位靚仔將就著看看吧。

  • 原版的喜馬拉雅PC頁面

image-20210314165954339

  • 仿製的喜馬拉雅頁面

總結

上面倆組圖片,細節方面對比基本慘不忍睹,但是整體架構上還是比較相似。

建議各位彥祖,下載下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

image-20210314212412718

  • 實現程式碼:關於業務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 選擇右箭頭圖片

image-20210314214406466

  • 填上方法名後,就能自動生成一個widget方法
  • 如果你提取的Widget塊中,還含有一些資料,自動生成的方法都會帶上相應引數,非常方便

image-20210314214520198

單層列表樣式封裝

類列表樣式的封裝也是比較關鍵的,直接從頭莽尾式的提取是不行,這邊有一絲調整

這裡就以猜你喜歡模組舉例

  • 猜你喜歡模組

image-20210314220037075

  • 程式碼分析:總體是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資料控制)

image-20210314221811228

  • 程式碼實現

    • 上面的佈局整體是由資料來源驅動頁面,資料能控制頁面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的各種痛點!

相關文章