Flutter Navigator 詳解

[TOC]

1. Navigator Widget Tree

首先君编,我們可以通過 DevTools 查看一個普通 Flutter App 的 Widget 樹結(jié)構(gòu),與 Navigator 相關(guān)的 Widget 如下圖:

Navigator Widget Tree

幾個關(guān)鍵的 Widget 分別是 Navigator、Overlay 和 Threatre烈和,接下來我們通過閱讀源碼來看看 Flutter 是如何通過這幾個 Widget 來組織頁面(Route)的饺藤。

1.1 Navigator

class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
  // 頁面(Route)棧
  List<_RouteEntry> _history = <_RouteEntry>[];
  
  Iterable<OverlayEntry> get _allRouteOverlayEntries sync* {
    for (final _RouteEntry entry in _history)
      yield* entry.route.overlayEntries;
  }
  
  @override
  Widget build(BuildContext context) {
    return Overlay(
      key: _overlayKey,
      initialEntries: overlay == null ?  _allRouteOverlayEntries.toList(growable: false) : const <OverlayEntry>[],
    );
  }
}

// _RouteEntry 是對 Route 的封裝,處理 Add干旧、Push渠欺、Pop 等路由事件
class _RouteEntry extends RouteTransitionRecord {
  final Route<dynamic> route;
  void handleAdd() {...}
  void handlePush() {...}
  void handlePop() {...}
}

// 一個頁面(Route)可以創(chuàng)建多個 OverlayEntry
abstract class Route<T> {
  RouteSettings _settings;
  List<OverlayEntry> get overlayEntries => const <OverlayEntry>[];
}

// widgets/overlay.dart
class OverlayEntry {
  // 用于構(gòu)建 Widget
  final WidgetBuilder builder;
  // Overlay 是不是不透明的,有什么用后面會提到
  bool _opaque;
}

可見椎眯,Navigator 負(fù)責(zé)將頁面棧中所有頁面包含的 OverlayEntry 組織成一個 List挠将,傳遞給 Overlay。

1.2 Overlay

// Overlay 負(fù)責(zé)根據(jù) OverlayEntry 的 opaque 屬性编整,判斷哪些 OverlayEntry 在前臺(onstage)舔稀,
// 哪些在后臺,計算出前臺 OverlayEntry 的數(shù)量掌测,并將其交給 Theatre
class OverlayState extends State<Overlay> {
  @override
  Widget build(BuildContext context) {
    final List<Widget> children = <Widget>[];
    bool onstage = true;
    int onstageCount = 0;
    for (int i = _entries.length - 1; i >= 0; i -= 1) {
      final OverlayEntry entry = _entries[I];
      if (onstage) {
        onstageCount += 1;
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
        ));
        if (entry.opaque)
          onstage = false;
      } else if (entry.maintainState) {
        // maintainState 為 true 時内贮,即使 Route 處于 Offstage 狀態(tài),
        // Widget 的 build() 仍會執(zhí)行汞斧,但不會渲染
        // CupertinoPageRoute 的 maintainState 默認(rèn)為 true
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
          tickerEnabled: false,
        ));
      }
    }
    return _Theatre(
      skipCount: children.length - onstageCount,
      children: children.reversed.toList(growable: false),
    );
  }
}

1.3 Theatre

class _Theatre extends MultiChildRenderObjectWidget {
  @override
  _RenderTheatre createRenderObject(BuildContext context) {
    return _RenderTheatre(
      skipCount: skipCount,
      textDirection: Directionality.of(context),
    );
  }
}

class _RenderTheatre extends RenderBox with ContainerRenderObjectMixin<RenderBox, StackParentData> {
  @override
  void paint(PaintingContext context, Offset offset) {
    RenderBox child = _firstOnstageChild;
    while (child != null) {
      final StackParentData childParentData = child.parentData as StackParentData;
      context.paintChild(child, childParentData.offset + offset);
      child = childParentData.nextSibling;
    }
  }
  
  RenderBox get _firstOnstageChild {
    RenderBox child = super.firstChild;
    for (int toSkip = skipCount; toSkip > 0; toSkip--) {
      final StackParentData childParentData = child.parentData as StackParentData;
      child = childParentData.nextSibling;
      assert(child != null);
    }
    return child;
  }
}

_Theatre 會跳過 offstage Overlay夜郁,只繪制 onstage Overlay。

2. Route

上一章我們看到粘勒,Navigator 實(shí)質(zhì)上管理的是 RouteEntry竞端,RouteEntry 是對 Route 和 Route 生命周期的封裝。首先庙睡,我們來看看 Flutter 為我們提供了哪些 Route事富。

2.1 Route Family

Route Family

2.2 Route Lifecycle

跟原生系統(tǒng)一樣,F(xiàn)lutter Route 也有自己的生命周期乘陪。
Navigator 2.0 對 Route 生命周期做了一次大重構(gòu)统台。

Route Lifecycle

3. 應(yīng)用

3.1 Toast

class Toast {
  static void show(BuildContext context, String msg, [int lengthInMillis]) async {
    // 創(chuàng)建一個 OverlayEntry, opaque = false
    final overlayEntry = OverlayEntry(
      builder: (context) => _ToastWidget(),
      opaque: false,
    );
    // 直接往 OverlayState 里插入 OverlayEntry
    final overlayState = Overlay.of(context);
    overlayState.insert(overlayEntry);
    // 展示一段時間后再從 OverlayState 中移除自己
    await Future.delayed(Duration(milliseconds: lengthInMillis ?? Toast.lengthShort));
    overlayEntry?.remove();
  }
}

項(xiàng)目中其他直接使用 Overlay 的場景:

  • Bottom Sheet
  • iOS Keyboard Header
  • Progress Hud

其他典型場景:

  • Drag
  • Hero

3.2 LocalHistoryRoute

我們項(xiàng)目中大量使用 LocalHistoryRoute 來實(shí)現(xiàn)頁面退出事件攔截:

abstract class BasePageState<T extends StatefulWidget> extends State<T> with RouteAware {
  void addLocalHistoryEntry(BuildContext context) {
    _localHistoryEntry = LocalHistoryEntry(
      onRemove: onRemove,
    );
    ModalRoute.of(context).addLocalHistoryEntry(_localHistoryEntry);
  }
}

abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T> {
}

mixin LocalHistoryRoute<T> on Route<T> {
  void addLocalHistoryEntry(LocalHistoryEntry entry) 
    entry._owner = this;
    _localHistory ??= <LocalHistoryEntry>[];
    final bool wasEmpty = _localHistory.isEmpty;
    _localHistory.add(entry);
    if (wasEmpty)
      changedInternalState();
  }
  
  @override
  bool didPop(T result) {
    if (_localHistory != null && _localHistory.isNotEmpty) {
      final LocalHistoryEntry entry = _localHistory.removeLast();
      entry._owner = null;
      entry._notifyRemoved();
      if (_localHistory.isEmpty)
        changedInternalState();
      return false;
    }
    return super.didPop(result);
  }
}

3.3 Push Replacement Bug

我們項(xiàng)目中會創(chuàng)建一個 PageNavigatorObserver 對象監(jiān)聽路由事件啡邑,然后做一些 PageFlow 相關(guān)的邏輯處理:

class PageNavigatorObserver extends NavigatorObserver {
  @override
  void didPush(Route route, Route previousRoute) {...}
  
  @override
  void didPop(Route route, Route previousRoute) {...}
  
  @override
  void didReplace({Route newRoute, Route oldRoute}) {...}
}

考慮以下場景:

Pop And Push Replacement

NavigatorObserver disReplace 回調(diào)給的 oldRoute 值有誤贱勃。

首先,我們需要先分析 pushReplacement() 調(diào)用前谣拣,各個 Route 處于生命周期哪個階段:

  • A: idle
  • B: 因?yàn)?B 此時還在轉(zhuǎn)場募寨,所以狀態(tài)是 poping

然后我們來看看 Navigator pushReplacement() 實(shí)現(xiàn)有什么問題:

class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
  Future<T> pushReplacement<T extends Object, TO extends Object>(Route<T> newRoute, { TO result }) {
    // Present: add, adding, push, pushReplace, pushing, replace, idle, pop, remove
    // 先將 A 置為 remove
    _history.lastWhere(_RouteEntry.isPresentPredicate).complete(result, isReplaced: true);
    // 將 C 入棧,初始狀態(tài)為 pushReplace
    _history.add(_RouteEntry(newRoute, initialState: _RouteLifecycle.pushReplace));
    // 這個方法是 Navigator 的核心森缠,負(fù)責(zé) Route 生命周期調(diào)度
    _flushHistoryUpdates();
  }
}

_flushHistoryUpdates 調(diào)用前拔鹰,各個 Route 處于生命周期哪個階段:

  • A: remove
  • B: poping
  • C: pushReplace

再來看看 _flushHistoryUpdates 相關(guān)實(shí)現(xiàn):

class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
  void _flushHistoryUpdates({bool rearrangeOverlay = true}) {
    // 從后往前遍歷 _history
    int index = _history.length - 1;
    _RouteEntry previous = index > 0 ? _history[index - 1] : null;
    while (index >= 0) {
      switch (entry.currentState) {
        ...
        case _RouteLifecycle.push:
        case _RouteLifecycle.pushReplace:
        case _RouteLifecycle.replace:
          entry.handlePush(
            navigator: this,
            previous: previous?.route, // B
            previousPresent: _getRouteBefore(index - 1, _RouteEntry.isPresentPredicate)?.route, // A
            isNewFirst: next == null,
          );
          break;
      }
      index -= 1;
      previous = index > 0 ? _history[index - 1] : null;
    }
  }
}

// previous: B, previousPresent: A
class _RouteEntry extends RouteTransitionRecord {
  void handlePush({ @required NavigatorState navigator, @required bool isNewFirst, @required Route<dynamic> previous, @required Route<dynamic> previousPresent }) {
    ...
    if (previousState == _RouteLifecycle.replace || previousState == _RouteLifecycle.pushReplace) {
      for (final NavigatorObserver observer in navigator.widget.observers)
        observer.didReplace(newRoute: route, oldRoute: previous); // What?
    } else {
      for (final NavigatorObserver observer in navigator.widget.observers)
        observer.didPush(route, previousPresent);
    }
  }
}

到此,已經(jīng)找到 didReplace 參數(shù)錯誤的根源贵涵,正確寫法:

observer.didReplace(newRoute: route, oldRoute: previous);
->
observer.didReplace(newRoute: route, oldRoute: previousPresent);

再看看 Google 已經(jīng)在 Flutter Stable 1.20 悄悄做了修復(fù)列肢。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末恰画,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子瓷马,更是在濱河造成了極大的恐慌拴还,老刑警劉巖,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件欧聘,死亡現(xiàn)場離奇詭異片林,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)怀骤,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進(jìn)店門费封,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人蒋伦,你說我怎么就攤上這事弓摘。” “怎么了痕届?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵韧献,是天一觀的道長。 經(jīng)常有香客問我研叫,道長锤窑,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任嚷炉,我火速辦了婚禮果复,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘渤昌。我一直安慰自己,他們只是感情好走搁,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布独柑。 她就那樣靜靜地躺著,像睡著了一般私植。 火紅的嫁衣襯著肌膚如雪忌栅。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天曲稼,我揣著相機(jī)與錄音索绪,去河邊找鬼。 笑死贫悄,一個胖子當(dāng)著我的面吹牛瑞驱,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播窄坦,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼唤反,長吁一口氣:“原來是場噩夢啊……” “哼凳寺!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起彤侍,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤肠缨,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后盏阶,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體晒奕,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年名斟,在試婚紗的時候發(fā)現(xiàn)自己被綠了脑慧。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡蒸眠,死狀恐怖漾橙,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情楞卡,我是刑警寧澤霜运,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站蒋腮,受9級特大地震影響淘捡,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜池摧,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一焦除、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧作彤,春花似錦膘魄、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至绢慢,卻和暖如春灿渴,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背胰舆。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工骚露, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人缚窿。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓棘幸,卻偏偏與公主長得像,于是被迫代替她去往敵國和親倦零。 傳聞我的和親對象是個殘疾皇子够话,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評論 2 344