【開發經驗】Flutter元件的事件傳遞與資料控制

遠在遠方的遠發表於2020-07-22

本文使用原生Flutter形式設計程式碼,只講最基礎的東西,不使用任何其他第三方庫(Provider等)

寫了接近兩年的Flutter,發現資料與事件的傳遞是新手在學習時經常問的問題:有很多初學者錯誤的在非常早期就引入providerBLOC等模式去管理資料,過量使用外部框架,造成專案混亂難以組織程式碼。其主要的原因就是因為忽視了基礎的,最簡單資料傳遞方式

很難想象有人把全部資料放在一個頂層provider裡,然後絕對不寫StatefulWidget。這種專案反正我是不維護,誰愛看誰看。

本文會列舉基本的事件與方法傳遞方式,並且舉例子講明如何使用基礎的方式實現這些功能。 本文的例子都基於flutter預設的加法demo修改,在dartpad或者新建flutter專案中即可執行本專案的程式碼例子。

在區域性傳遞資料與事件

先來看下基本的幾個應用情況,只要實現了這些情況,在區域性就可以非常流暢的傳遞資料與事件:

注意思考:下文的Widget,哪些是StatefulWidget

描述:一個Widget收到事件後,改變child顯示的值
實現功能:點選加號讓數字+1
難度:⭐

描述:一個Widget在child收到事件時,改變自己的值
實現功能:點選改變頁面顏色
難度:⭐

描述:一個Widget在child收到事件時,觸發自己的state的方法 實現功能:點選發起網路請求,重新整理當前頁面
難度:⭐

描述:一個Widget自己改變自己的值 實現功能:倒數計時,從網路載入資料
難度:⭐⭐⭐

描述:一個Widget自己的資料變化時,觸發state的方法
實現功能:一個在資料改變時播放過渡動畫的元件
難度:⭐⭐⭐⭐

描述:一個Widget收到事件後,觸發childstate的方法
實現功能:點選按鈕讓一個child開始倒數計時或者傳送請求
難度:⭐⭐⭐⭐⭐

我們平時寫專案基本也就是上面這些需求了,只要學會實現這些事件與資料傳遞,就可以輕鬆寫出任何專案了。

使用回撥傳遞事件

使用簡單的回撥就可以實現這幾個需求,這也是整個flutter的基礎:如何改變一個state內的資料,以及如何改變一個widget的資料。

描述:一個widget收到事件後,改變child顯示的值
實現功能:點選加號讓數字+1

描述:一個widgetchild收到事件時,改變自己的值
實現功能:點選改變頁面顏色

描述:一個widgetchild收到事件時,觸發自己的state的方法
實現功能:點選發起網路請求,重新整理當前頁面

這幾個都是毫無難度的,我們直接看同一段程式碼就行了

程式碼:

/// 這段程式碼是使用官方的程式碼修改的,通常情況下,只需要使用回撥就能獲取點選事件
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    // 在按鈕的回撥中,你可以設定資料與呼叫方法
    // 在這裡,讓計數器+1後重新整理頁面
    setState(() {
      _counter++;
    });
  }

  // setState後就會使用新的資料重新進行build
  // flutter的build效能非常強,甚至支援每秒60次rebuild
  // 所以不必過於擔心觸發build,但是要偶爾注意超大範圍的build
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Text(
          '$_counter',
          style: Theme.of(context).textTheme.headline4,
        ),
      ),
      floatingActionButton: _AddButton(
        onAdd: _incrementCounter,
      ),
    );
  }
}

/// 一般會使用GestureDetector來獲取點選事件
/// 因為官方的FloatingActionButton會自帶樣式,一般我們會自己寫按鈕樣式
class _AddButton extends StatelessWidget {
  final Function onAdd;

  const _AddButton({Key key, this.onAdd}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: onAdd,
      child: Icon(Icons.add),
    );
  }
}

複製程式碼

這種方式十分的簡單,只需要在回撥中改變資料,再setState就會觸發build方法,根據當前的資料重新build當前widget,這也是flutter最基本的重新整理方法。

在State中改變資料

flutter中,只有StatefulWidget才具有statestate才具有傳統意義上的生命週期(而不是頁面),通過這些週期,可以做到一進入頁面,就開始從伺服器載入資料,也可以讓一個Widget自動播放動畫

我們先看這個需求:

描述:一個Widget自己改變自己的值
實現功能:倒數計時,從網路載入資料

這也是一個常見的需求,但是很多新手寫到這裡就不會寫了,可能會錯誤的去使用FutureBuilder進行網路請求,會造成每次都反覆請求,實際上這裡是必須使用StatefulWidgetstate來儲存請求返回資訊的。

一般專案中,動畫,倒數計時,非同步請求此類功能需要使用state,其他大多數的功能並不需要存在state

例如這個widget,會顯示一個數字:

class _CounterText extends StatelessWidget {
  final int count;

  const _CounterText({Key key, this.count}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text('$count'),
    );
  }
}
複製程式碼

可以試著讓widget從伺服器載入這個數字:

class _CounterText extends StatefulWidget {
  const _CounterText({Key key}) : super(key: key);

  @override
  __CounterTextState createState() => __CounterTextState();
}

class __CounterTextState extends State<_CounterText> {
  @override
  void initState() {
    // 在initState中發出請求
    _fetchData();
    super.initState();
  }

  // 在資料載入之前,顯示0
  int count = 0;

  // 載入資料,模擬一個非同步,請求後重新整理
  Future<void> _fetchData() async {
    await Future.delayed(Duration(seconds: 1));
    setState(() {
      count = 10;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text('$count'),
    );
  }
}
複製程式碼

又或者,我們想讓這個數字每秒都減1,最小到0。那麼只需要把他變成stateful後,在initState中初始化一個timer,讓數字減小:

class _CounterText extends StatefulWidget {
  final int initCount;

  const _CounterText({Key key, this.initCount:10}) : super(key: key);

  @override
  __CounterTextState createState() => __CounterTextState();
}

class __CounterTextState extends State<_CounterText> {
  Timer _timer;

  int count = 0;

  @override
  void initState() {
    count = widget.initCount;
    _timer = Timer.periodic(
      Duration(seconds: 1),
      (timer) {
        if (count > 0) {
          setState(() {
            count--;
          });
        }
      },
    );
    super.initState();
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text('${widget.initCount}'),
    );
  }
}
複製程式碼

這樣我們就能看到這個widget從輸入的數字每秒減少1。

由此可見,widget可以在state中改變資料,這樣我們在使用StatefulWidget時,只需要給其初始資料,widget會根據生命週期載入或改變資料。

在這裡,我建議的用法是在Scaffold中載入資料,每個頁面都由一個StatefulScaffold和若干StatelessWidget組成,由ScaffoldState管理所有資料,再重新整理即可。

注意,即使這個頁面的body是ListView,也不推薦ListView管理自己的state,在當前state維護資料的list即可。使用ListView.builder構建列表即可避免更新陣列時,在頁面上重新整理列表的全部元素,保持高效能重新整理。

在State中監聽widget變化

描述:一個Widget自己的資料變化時,觸發state的方法
實現功能:一個在資料改變時播放過渡動畫的元件

做這個之前,我們先看一個簡單的需求:一行widget,接受一個數字,數字是偶數時,距離左邊24px,奇數時距離左邊60px

這個肯定很簡單,我們直接StatelessWidget就寫出來了;

class _Row extends StatelessWidget {
  final int number;

  const _Row({
    Key key,
    this.number,
  }) : super(key: key);

  double get leftPadding => number % 2 == 1 ? 60.0 : 24.0;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      width: double.infinity,
      alignment: Alignment.centerLeft,
      padding: EdgeInsets.only(
        left: leftPadding,
      ),
      child: Text('$number'),
    );
  }
}
複製程式碼

這樣就簡單的實現了這個效果,但是實際執行的時候發現,數字左右橫跳,很不美觀。看來就有必要優化這個widget,讓他左右移動的時候播放動畫,移動過去,而不是跳來跳去。

一個比較簡單的方案是,傳入一個AnimationController來精確控制,但是這樣太複雜了。這種場景下,我們在使用的時候通常只想更新數字,再setState,就希望他在內部播放動畫(通常是過渡動畫),就可以不用去操作複雜的AnimationController了。

實際上,這個時候我們使用didUpdateWidget這個生命週期就可以了,在state所依附的widget更新時,就會觸發這個回撥,你可以在這裡響應上層傳遞的資料的更新,在內部播放動畫。

程式碼:

class _Row extends StatefulWidget {
  final int number;

  const _Row({
    Key key,
    this.number,
  }) : super(key: key);

  @override
  __RowState createState() => __RowState();
}

class __RowState extends State<_Row> with TickerProviderStateMixin {
  AnimationController animationController;

  double get leftPadding => widget.number % 2 == 1 ? 60.0 : 24.0;

  @override
  void initState() {
    animationController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 500),
      lowerBound: 24,
      upperBound: 60,
    );
    animationController.addListener(() {
      setState(() {});
    });
    super.initState();
  }
  
  // widget更新,就會觸發這個方法
  @override
  void didUpdateWidget(_Row oldWidget) {
    // 播放動畫去當前位置
    animationController.animateTo(leftPadding);
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      width: double.infinity,
      alignment: Alignment.centerLeft,
      padding: EdgeInsets.only(
        left: animationController.value,
      ),
      child: Text('${widget.number}'),
    );
  }
}

複製程式碼

這樣在狀態之間就完成了一個非常平滑的動畫切換,再也不會左右橫跳了。

方法3: 傳遞ValueNotifier/自定義Controller

這裡我們還是先看需求

描述:一個Widget收到事件後,觸發childstate的方法 實現功能:點選按鈕讓一個child開始倒數計時或者傳送請求(呼叫state的方法) 難度:⭐⭐⭐⭐⭐

首先必須明確的是,如果出現在業務邏輯裡,這裡是顯然不合理,是需要避免的。StatefulWidget巢狀時應當避免互相呼叫方法,在這種時候,最好是將childstate中的方法與資料,向上提取放到當前層state中。

這裡可以簡單分析一下:

  1. 有資料變化
    有資料變化時,使用StatedidUpdateWidget生命週期更加合理。這裡我們也可以勉強實現一下,在flutter框架中,我推薦使用ValueNotifier進行傳遞,child監聽ValueNotifier即可。

  2. 沒有資料變化 沒有資料變化就比較麻煩了,我們需要一個controller進去,然後child註冊一個回撥進controller,這樣就可以通過controller控制。

這裡也可以使用providereventbus等庫,或者用keyglobalKey相關方法實現。但是,必須再強調一次:不管用什麼方式實現,這種巢狀是不合理的,專案中需要互相呼叫state的方法時,應當合併寫在一個state裡。原則上,需要避免此種巢狀,無論如何實現,都不應當是專案中的通用做法。

雖然不推薦在業務程式碼中這樣寫,但是在框架的程式碼中是可以寫這種結構的(因為必須暴露介面)。這種情況可以參考ScrollController,你可以通過這個Controller控制滑動狀態。

值得一提的是:ScrollController繼承自ValueNotifier。所以使用ValueNotifier仍然是推薦做法。

其實controller模式也是flutter原始碼中常見的模式,一般用於對外暴露封裝的方法。controller相比於其他的方法,比較複雜,好在我們不會經常用到。

作為例子,讓我們實現一個CountController類,來幫我們呼叫元件內部的方法。

程式碼:

class CountController extends ValueNotifier<int> {
  CountController(int value) : super(value);

  // 逐個增加到目標數字
  Future<void> countTo(int target) async {
    int delta = target - value;
    for (var i = 0; i < delta.abs(); i++) {
      await Future.delayed(Duration(milliseconds: 1000 ~/ delta.abs()));
      this.value += delta ~/ delta.abs();
    }
  }

  // 實在想不出什麼例子了,總之是可以這樣呼叫方法
  void customFunction() {
    _onCustomFunctionCall?.call();
  }

  // 目標state註冊這個方法
  Function _onCustomFunctionCall;
}

class _Row extends StatefulWidget {
  final CountController controller;
  const _Row({
    Key key,
    @required this.controller,
  }) : super(key: key);

  @override
  __RowState createState() => __RowState();
}

class __RowState extends State<_Row> with TickerProviderStateMixin {
  @override
  void initState() {
    widget.controller.addListener(() {
      setState(() {});
    });
    widget.controller._onCustomFunctionCall = () {
      print('響應方法呼叫');
    };
    super.initState();
  }

  // 這裡controller應該是在外面dispose
  // @override
  // void dispose() {
  //   widget.controller.dispose();
  //   super.dispose();
  // }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      width: double.infinity,
      alignment: Alignment.centerLeft,
      padding: EdgeInsets.only(
        left: 24,
      ),
      child: Text('${widget.controller.value}'),
    );
  }
}

複製程式碼

使用controller可以完全控制下一層state的資料和方法呼叫,比較靈活。但是程式碼量大,業務中應當避免寫這種模式,只在複雜的地方構建controller來控制資料。如果你寫了很多自定義controller,那應該反思你的專案結構是不是出了問題。無論如何實現,這種傳遞方式都不應當是專案中的通用做法。

單例管理全域性資料與事件

全域性的資料,可以使用頂層provider或者單例管理,我的習慣是使用單例,這樣獲取資料可以不依賴context

簡單的單例寫法,擴充套件任何屬性到單例即可。

class Manager {
  // 工廠模式
  factory Manager() =>_getInstance();
  static Manager get instance => _getInstance();
  static Manager _instance;
  Manager._internal() {
    // 初始化
  }
  static Manager _getInstance() {
    if (_instance == null) {
      _instance = new Manager._internal();
    }
    return _instance;
  }
}
複製程式碼

總結

作者:馬嘉倫 日期:2020/07/22 平臺:Segmentfault,掘金社群,勿轉載

我的其他文章: 【開發經驗】Flutter避免程式碼巢狀,寫好build方法 【Flutter工具】fmaker:自動生成倍率切圖/自動更換App圖示 【Flutter應用】Flutter精仿抖音開源 【Flutter工具】可能是Flutter上最簡單的本地資料儲存方案

寫這篇文章的原因,是因為看到不少人在學習flutter時,對於資料與事件的傳遞非常的不熟悉,又很早的去學習provider等第三方框架,對於基礎的東西又一知半解,導致程式碼混亂專案混亂,不知如何傳遞資料,如何去重新整理介面。所以寫這篇文章總結了最基礎的各種事件與資料的傳遞方法。

簡單總結,flutter改變資料最基礎的就是這麼幾種模式:

  • 改變自己state的資料,setStatechild傳遞新資料
  • 接受child的事件回撥
  • child更新目標資料,child監聽資料的變化,更加細節的改變自己的state
  • child傳遞controller,全面控制childstate

專案中只需要這幾種模式就能很簡單的全部寫完了,使用provider等其他的庫,程式碼上並不會有特別大的改善和進步。還是希望大家學習flutter的時候,能先摸清基本的寫法,再進行更深層次的學習。

相關文章