重磅! flutter檢視區域性更新

林鹿發表於2019-09-22

新建一個flutter工程, 以flutter框架給我們自動生成的程式碼為例, 當我們點選按鈕更新記數_counter時,最終是通過呼叫State<T>.setState來更新檢視的:

setState(() {
  _counter++;
})
複製程式碼

首先需要理解為什麼要setState, 它表示當前節點的資料變更,通知檢視需要更新.更新哪個檢視? 持有當前這個State例項的節點對應的檢視. 注意這個節點具體指的是Element物件, Widget只是建立State例項(_MyHomePageState createState()),並沒有持有, 同樣State又繼續建立了子檢視,也沒有持有子檢視(Widget build(BuildContext context)), 持有State的只有Element. setState的引數是一個方法執行體, 實現哪些資料的具體變更, 所以其實沒有設定所謂的狀態, 還不如叫notifyChanges來的明晰.

其次需要理解檢視如何更新. 像Text那個控制元件, 文字是作為建構函式的引數直接傳給控制元件的, 根本連類似setText的方法也沒有! 所以顯示出來的資料要更新除了新建檢視物件外沒有別的辦法!

這裡就體現了flutter與傳統移動端介面開發的巨大不同: 檢視是通過新建檢視物件來完成更新的. 以往的介面開發中檢視物件都是一個比較重比較大的物件, 檢視要避免冗餘, 要儘量複用, 不要頻繁建立. 但在flutter中就不是這樣了, 代表檢視物件的Widget是輕量物件, 它不持有State, 也不持有Widget, 所有檢視物件都是通過build這種建立型關係建立. 所以開發過程中也要堅決避免自定義的Widget持有資料, 因為Widget物件會被很快替換掉.

有了上述兩點就能明白setState之後發生了什麼: 當前_MyHomePageStateWidget build(BuildContext context)方法會被呼叫, 於是生成了新的Scaffold物件,連帶著AppBar,FloatingActionButton,Column一干控制元件其中自然包括我們需要展示的Text物件, 這時傳入的文字是更新過後的_counter,於是檢視得以更新.

只是想更新一個個小小的文字框就不得不重新建立整個檢視?!

對, 目前的機制就是這樣. 那隨著檢視層次加深, 介面互動複雜,這種重新建立型操作就沒有一點問題? 畢竟物件再小也有開銷, 那麼多物件累積起來,也可能造成建立過程的消耗.於是我們的問題終於來了: 有沒有方法可以只更新部分檢視?

縮小一下更新範圍不就得了? 現在的更新範圍大是因為_MyHomePageState.build被呼叫返回了整個檢視, 而_MyHomePageState對應的檢視是MyHomePage. 所以建立一個State<Text>, build返回Text控制元件例項, 再將這個State<Text>持有, 資料變更時呼叫State<Text>.setState()`不就可以達到目的?

這個想法符合flutter本身的機制, 但問題就是誰來建立這個State<Text>? 如前文所述, 首先只有StatefulWidget才能建立State例項, 其次必須是父節點建立這個State<Text>. 但示例中Text的父節點Column首先就不是StatefulWidget; 就算是了, 我們還要宣告Widget類繼承Column覆蓋build方法, 再宣告State類繼承State<Text>, 煩都煩死了. 那如果從Text向上找一個StatefulWidget, 建立的時候是Text的一個祖先節點, 存在一點冗餘可以接受呢? 這個想法實踐上一點也不可行, 且不說有個特定檢視物件的查詢過程, 上面所說的各種類宣告一點也沒有減少, 所以這個路子是沒法搞的.

所以還是從setState原始碼入手, 看一個節點到底是如何更新檢視的.

State.setState
  Element.markNeedsBuild
    Element._dirty = true;
    BuildOwner.scheduleBuildFor
      BuildOwner._dirtyElements.add
      Element._inDirtyList = true;
複製程式碼

過程比想象的簡單, 最後僅僅是將Element節點標識成dirty並加入到了BuildOwner的_dirtyElements列表裡. 從Element角度看setState這個名稱似乎也沒有錯, 不過它是相對Element說的, 具體設定的是Elementdirty狀態. 那我們只需找到Text對應的Element節點並呼叫一下它的markNeedsBuild不就ok了? 所以先要找到Text這個Widget節點對應的Element節點.

在以前的建樹流程中說過Element節點結構像掛鉤, Element只有parent沒有children, 要找子節點需要像Element.visitChildren那樣傳遞一個訪問者來進行遍歷, 而判斷條件自然就是Element持有的Widget是否是我們需要更新的Widget, 於是有:

  static Element findChild(Element e, Widget w) {
    Element child;
    void visit(Element element) {
      if (w == element.widget)
        child = element;
      else
        element.visitChildren(visit);
    }
    visit(e);
    return child;
  }
複製程式碼

但是對找到的element設定markNeedsBuild竟然不起作用! 查了半天原因, 才明白還是把建樹流程搞混了, markNeedsBuild僅讓當前Element節點的build被呼叫, 建立的是當前節點的子節點檢視物件, 而我們現在需要的是把當前子節點持有的檢視物件替換掉('檢視更新是通過建立新的Widget物件'), 同時不能重新建立當前Element節點及其子節點. 而Element.update(Widget)正是這個作用!! 如果說inflateWidget是初始化Element節點樹, 那update正是在樹建立成功後進行更新操作. 於是有

onPressed: () {
  _counter++;
  Element e = findChild(context as Element, title);
  if (e != null) {
    e.update(title);
  }
},
複製程式碼

因為要找節點, 所以用了一個title持有了Text, 以方便在onTap()的上下文中作查詢引數. 但這樣也是不對的! 這裡存在2個問題:

  1. 檢視物件沒有更新. 我們需要展示的是一個新的_counter相關的文字, 因此需要的是一個新的檢視物件, 現在傳入的還是老的檢視物件,等於什麼也沒更新...
  2. 直接呼叫Element.update是有異常的, 跟蹤了一下發現一個標識狀態的資料_debugStateLockLevel不對, 原來要在BuildOwner.lockState中執行才可以.

這裡囉裡八嗦的寫這一坨是想表明一個的新想法的實現是環環相扣關聯細節的, 很多時候思路是對的, 但細節實現錯誤導致半途而廢, 行百里者半九十!

還是上完整程式碼, findChild前面已定義就不再貼了:

import 'package:flutter/foundation.dart'
import 'package:flutter/material.dart';
import 'utils/ElementUtils.dart';

void main() {
  runApp(new MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Pages'),
    );
  }
}

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;

  @override
  Widget build(BuildContext context) {
    Widget title = new Text(
      'another times: $_counter',
    );
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
            title,
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _counter++;
          Element e = findChild(context as Element, title);
          if (e != null) {
            title = new Text(
              'another times: $_counter',
            );
            e.owner.lockState(() {
              e.update(title);
            });
          }
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}
複製程式碼

現在只是重新建立了僅僅一個檢視哦, 它不快都不行~!

然而還是需要考慮一下這麼做的缺點或者劣勢是什麼

首先, 明顯的存在一個查詢操作, 這是由Element機制決定的, 遍歷只能通過訪問者模式, 時間複雜度O(n), 能不能避免這個查詢或者建立Widget到Element的對映? 也可以, 但是至少要查詢一次,因為建立widget的時候Element可能還沒建立或者還沒有關聯, 只有Element樹建立完成之後才能查的到.

其次, 如果一個操作涉及多個檢視的更新, 我們不得不持有多個widget, 並查詢多個widget對應的element, 還是有多個查詢操作, 這麼麻煩還不如全部新建呢.

所以只能視情況而定, 沒有包打天下一勞永逸的方案, 合適的才是最好的!

相關文章