Flutter-為什麼包裝一層Builder控制元件之後,路由或點選彈框事件正常使用了?

XuYanjun 發表於 2019-10-10

事故回放

一朋友面試,被問到在Flutter中一些因 context 引起的路由異常的問題,為什麼包裝一層 Builder 控制元件之後,路由或點選彈框事件正常使用了?然後就沒然後了。。。相信很多人都會用,至於為什麼,也沒深究。

相信很多剛開始玩Flutter的同學都會在學習過程中都會寫到類似下面的這種程式碼:

import 'package:flutter/material.dart';

class BuilderA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: GestureDetector(
          onTap: () {
            Scaffold.of(context).showSnackBar(SnackBar(
              content: Text('666666'),
            ));
          },
          child: Center(
            child: Container(
              width: 100,
              height: 100,
              color: Colors.red,
            ),
          ),
        ),
      ),
    );
  }
}
複製程式碼

開開心心寫完,然後一頓執行:

void main() => runApp(BuilderA());
複製程式碼

點選,發現 SnackBar 並沒有正常彈出,而是出現了下面這種異常:

════════ Exception caught by gesture
═══════════════════════════════════════════════════════════════
The following assertion was thrown while handling a gesture:
Scaffold.of() called with a context that does not contain a Scaffold.
...

網上很多資料都說需要外包一層 Builder 可以解決這種問題,但是基本上沒說原因,至於為什麼說可以外包一層 Builder 就可以解決,我想大部分只是看了 Scaffold 的原始碼中的註釋瞭解到的:

scaffold.dart 第1209行到1234行:
...
/// {@tool snippet --template=stateless_widget_material}
/// When the [Scaffold] is actually created in the same `build` function, the
/// `context` argument to the `build` function can't be used to find the
/// [Scaffold] (since it's "above" the widget being returned in the widget
/// tree). In such cases, the following technique with a [Builder] can be used
/// to provide a new scope with a [BuildContext] that is "under" the
/// [Scaffold]:
///
/// ```dart
/// Widget build(BuildContext context) {
///   return Scaffold(
///     appBar: AppBar(
///       title: Text('Demo')
///     ),
///     body: Builder(
///       // Create an inner BuildContext so that the onPressed methods
///       // can refer to the Scaffold with Scaffold.of().
///       builder: (BuildContext context) {
///         return Center(
///           child: RaisedButton(
///             child: Text('SHOW A SNACKBAR'),
///             onPressed: () {
///               Scaffold.of(context).showSnackBar(SnackBar(
///                 content: Text('Have a snack!'),
///               ));
///             },
...
複製程式碼

那到底是什麼原因外包一層 Builder 控制元件就可以了呢?


原因分析

異常原因

上面那種寫法為什麼會異常?要想知道這個問題,我們首先看這句描述:

Scaffold.of() called with a context that does not contain a Scaffold.

意思是說在不包含Scaffold的上下文中呼叫了Scaffold.of()
我們仔細看看這個程式碼,會發現,此處呼叫的 contextBuilderA 的,而在BuilderA中的 build 方法中我們才指定了 Scaffold ,因此確實是不存的。

為什麼包一層Builder就沒問題了?

我們把程式碼改成下面這種:

class BuilderB extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Builder(
          builder: (context) => GestureDetector(
            onTap: () {
              Scaffold.of(context).showSnackBar(SnackBar(
                content: Text('666666'),
              ));
            },
            child: Center(
              child: Container(
                width: 100,
                height: 100,
                color: Colors.red,
              ),
            ),
          ),
        ),
      ),
    );
  }
}
複製程式碼

執行之後發現確實沒問題了?為什麼呢?我們先來看看 Builder 原始碼:

// ##### framework.dart檔案下
typedef WidgetBuilder = Widget Function(BuildContext context);


// ##### basic.dart檔案下
class Builder extends StatelessWidget {
  /// Creates a widget that delegates its build to a callback.
  ///
  /// The [builder] argument must not be null.
  const Builder({
    Key key,
    @required this.builder,
  }) : assert(builder != null),
       super(key: key);

  /// Called to obtain the child widget.
  ///
  /// This function is called whenever this widget is included in its parent's
  /// build and the old widget (if any) that it synchronizes with has a distinct
  /// object identity. Typically the parent's build method will construct
  /// a new tree of widgets and so a new Builder child will not be [identical]
  /// to the corresponding old one.
  final WidgetBuilder builder;

  @override
  Widget build(BuildContext context) => builder(context);
}
複製程式碼

程式碼很簡單,Builder 類繼承 StatelessWidget ,然後通過一個介面回撥將自己對應的 context 回撥出來,供外部使用。沒了~ 但是!外部呼叫:

onTap: () {
  Scaffold.of(context).showSnackBar(SnackBar(
    content: Text('666666'),
  ));
}
複製程式碼

此時的 context 將不再是 BuilderBcontext 了,而是 Builder 自己的了!!!

那麼問題又來了~~~憑什麼改成 Builder 中的 context 就可以了?我能這個時候就不得不去看看 Scaffold.of(context) 的原始碼了:

...

static ScaffoldState of(BuildContext context, { bool nullOk = false }) {
    assert(nullOk != null);
    assert(context != null);
    final ScaffoldState result = context.ancestorStateOfType(const TypeMatcher<ScaffoldState>());
    if (nullOk || result != null)
      return result;
    throw FlutterError(
      
...省略不重要的
複製程式碼
  @override
  State ancestorStateOfType(TypeMatcher matcher) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    Element ancestor = _parent;
    while (ancestor != null) {
      if (ancestor is StatefulElement && matcher.check(ancestor.state))
        break;
      ancestor = ancestor._parent;
    }
    final StatefulElement statefulAncestor = ancestor;
    return statefulAncestor?.state;
  } 
複製程式碼

上面的核心部分揭露了原因: of() 方法中會根據傳入的 context 去尋找最近的相匹配的祖先 widget,如果尋找到返回結果,否則丟擲異常,丟擲的異常就是上面出現的異常!

此處,Builder 就在 Scafflod 節點下,因在 Builder 中呼叫 Scafflod.of(context) 剛好是根據 Builder 中的 context 向上尋找最近的祖先,然後就找到了對應的 Scafflod,因此這也就是為什麼包裝了一層 Builder 後就能正常的原因!

總結時刻

  • Builder 控制元件的作用,我的理解是在於重新提供一個新的子 context ,通過新的 context 關聯到相關祖先從而達到正常操作的目的。
  • 同樣的對於路由跳轉 Navigator.of(context)【注:Navigator 是由 MaterialApp 提供的】 等類似的問題,採用的都是類似的原理,只要搞懂了其中一個,其他的都不在話下!

當然,處理這類問題不僅僅這一種思路,道路千萬條,找到符合自己的那一條才是關鍵!