Flutter中Widget之Key原理探索

235 阅读5分钟
原文链接: zhuanlan.zhihu.com

开始

在Flutter的每个Widget中, 都会有key这个可选属性. 在刚开始学习flutter时, 基本就直接忽略不管了. 对执行结果好像也没什么影响. 现在来深究下key到底有什么作用.(研究一天时间, 发现key没什么作用. 快要放弃, 又多写了几个简单例子, 终于发现差异~~)

官方文档介绍

key用于控制控件如何取代树中的另一个控件.

如果2个控件的runtimeTypekey属性operator==, 那么新的控件通过更新底层元素来替换旧的控件(通过调用Element.update). 否则旧的控件将从树上删除, element会生成新的控件, 然后新的element会被插入到树中.

另外, 使用GlobalKey做为控件的key, 允许element在树周围移动(改变父节点), 而不会丢失状态. 当发现一个新的控件时(它的key和类型与同一位置上控件不匹配), 但是在前面的结构中有一个带有相同'key'的小部件, 那么这个控件将会被移动到新的位置.

GlobalKey是很昂贵的. 如果不需要使用上述特性, 可以考虑使用Key, ValueKeyUniqueKey替换.

通常, 只有一个子节点的widget不需要指定key.

实践

按文档的意思, 就是指定了相同的'key'属性, 就能让element复用.

为了弄清楚这些, 我们有必要了解, Flutter中控件的构建流程以及刷新流程. 简单的做法是在StatelessWidget.build或者StatefulWidget.build中下个断点, 调试运行, 等断点停下来. 在Debug视窗的'Frames'试图下可以看到函数的调用堆栈. 然后继续跑几步, 可以看到后面的调用流程.

调用流程

为了更直观的查看调用流程, 省去了MaterialAppScaffold的包裹.

例子1

void main() {
  runApp(Sample());
}

class Sample1 extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Text('Sample1', textDirection: TextDirection.ltr,);
  }
}

从这个调用栈我们就可以知道调用流程. 关键函数我直接提炼出来.

关键代码分析

关键代码 - Widget.canUpdate, 用于判断Widget是否能复用, 注意类型相同, key为空也是可以复用的. static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; }

  • Element.updateChild
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
    ....    
    if (newWidget == null) {
      if (child != null)
        deactivateChild(child);
      return null;
    }
    if (child != null) {
      if (child.widget == newWidget) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        return child;
      }
      if (Widget.canUpdate(child.widget, newWidget)) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        child.update(newWidget);
        assert(child.widget == newWidget);
        assert(() {
          child.owner._debugElementWasRebuilt(child);
          return true;
        }());
        return child;
      }
      deactivateChild(child);
      assert(child._parent == null);
    }
    return inflateWidget(newWidget, newSlot);
  }

第一次构建时, Element child参数为空, 我们需要将StatefulWidgetStatelessWidgetbuild出的Widget 传递给inflateWidget方法. 后面刷新界面时, Element child参数不为空时, 我们可以判断Element原来持有的widget和build新得出的widget是否相同. 什么情况下会这样了? 当我们缓存了Widget就会这样.

例子2

void main() {
  runApp(Sample2());
}

class _Sample2State extends State<Sample2> {
  int count = 0;
  Text cacheText;

  @override
  void initState() {
    cacheText = Text(
      'cache_text',
      textDirection: TextDirection.ltr,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        RaisedButton(
          child: Text(
            '$count',
            textDirection: TextDirection.ltr,
          ),
          onPressed: () {
            print(this.widget);
            setState(() {
              count += 1;
            });
          },
        ),
        cacheText,
        Text('no_cache_text', textDirection: TextDirection.ltr),
      ],
    );
  }
}

if (child.widget == newWidget)这里下个断点, 当点击按钮事刷新时, 我们就可以发现cache_text会进来, 而no_cache_text不会进来. no_cache_text会进入到if (Widget.canUpdate(child.widget, newWidget))里, 因为parent(Column)没变, 元素个数没变, 继续执行update(widget)

@mustCallSuper
  void update(covariant Widget newWidget) {
    assert(_debugLifecycleState == _ElementLifecycle.active
        && widget != null
        && newWidget != null
        && newWidget != widget
        && depth != null
        && _active
        && Widget.canUpdate(widget, newWidget));
    _widget = newWidget;
  }

这里其实就是修改了'Element'持有的_widget指向. 也就是文档里说的, Widget被切换, 而'Element'会被复用. 当这些都无法满足时, 就是执行inflateWidget来创建新的Element. 什么情况下会这样了, 当Widget.canUpdate不为真时, 就是当类型或key不同时. 下面举个例子:

例子3

class _Sample3State extends State<Sample3> {
  int count = 0;

  GlobalKey keyOne = GlobalKey();
  GlobalKey keyTwo = GlobalKey();

  @override
  void initState() {}

  @override
  Widget build(BuildContext context) {
    List<Widget> list = [
      RaisedButton(
        child: Text(
          '$count',
          textDirection: TextDirection.ltr,
        ),
        onPressed: () {
          print(this.widget);
          setState(() {
            count += 1;
          });
        },
      ),
      Text(
        'key${count%2}',
        textDirection: TextDirection.ltr,
        key: count % 2 == 0 ? keyOne : keyTwo,
      ),
    ];
    if (count % 2 == 0) {
      list.add(RaisedButton(
          onPressed: () {},
          child: Text('button text', textDirection: TextDirection.ltr)));
    } else {
      list.add(Text('just text', textDirection: TextDirection.ltr));
    }
    return Column(
      children: list,
    );
  }
}

Element.inflateWidget下个断点, 每次点击按钮, 都会调用inflateWidgetColumn的后面2个Widget. 如果去掉第二个控件的key属性, 则不会每次都执行Element.inflateWidget. 也就是说一般情况下, 我们无需指定key属性, Element就能复用.

文档里还指出使用了GlobalKey, 一个Element可以从树的一个位置复用到树的其它位置. 其相关核心代码在inflateWidget_retakeInactiveElement方法里.

  • Element.inflateWidget
Element inflateWidget(Widget newWidget, dynamic newSlot) {
    assert(newWidget != null);
    final Key key = newWidget.key;
    if (key is GlobalKey) {
      final Element newChild = _retakeInactiveElement(key, newWidget);
      if (newChild != null) {
        assert(newChild._parent == null);
        assert(() { _debugCheckForCycles(newChild); return true; }());
        newChild._activateWithParent(this, newSlot);
        final Element updatedChild = updateChild(newChild, newWidget, newSlot);
        assert(newChild == updatedChild);
        return updatedChild;
      }
    }
    final Element newChild = newWidget.createElement();
    assert(() { _debugCheckForCycles(newChild); return true; }());
    newChild.mount(this, newSlot);
    assert(newChild._debugLifecycleState == _ElementLifecycle.active);
    return newChild;
  }

  Element _retakeInactiveElement(GlobalKey key, Widget newWidget) {
    final Element element = key._currentElement;
    if (element == null)
      return null;
    if (!Widget.canUpdate(element.widget, newWidget))
      return null;
    assert(() {
      if (debugPrintGlobalKeyedWidgetLifecycle)
        debugPrint('Attempting to take $element from ${element._parent ?? "inactive elements list"} to put in $this.');
      return true;
    }());
    final Element parent = element._parent;
    if (parent != null) {
      assert(() {
        if (parent == this) {
          throw new FlutterError(
            ...      
          );
        }
        parent.owner._debugTrackElementThatWillNeedToBeRebuiltDueToGlobalKeyShenanigans(
          parent,
          key,
        );
        return true;
      }());
      parent.forgetChild(element);
      parent.deactivateChild(element);
    }
    assert(element._parent == null);
    owner._inactiveElements.remove(element);
    return element;
  }

代码大概意思是, 首先如果widgetkey值不为空, 会判断key._currentElement值所指向的widget, 和当前'widget'的类型'key'都相同. 那么就从旧的父节点上移除. 作为当前的节点的子widget之一. 否则将进行真实的创建新的Element. 下面举个例子

例子4

void main() {
  runApp(Sample4());
}

class Sample4 extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _Sample4State();
  }
}

class _Sample4State extends State<Sample4> {
  int count = 0;
  GlobalKey key = GlobalKey();

  @override
  Widget build(BuildContext context) {
    var child;
    if (count % 2 == 0) {
      child = Padding(
        padding: EdgeInsets.all(10.0),
        child: Text(
          'text 2',
          textDirection: TextDirection.ltr,
          key: key,
        ),
      );
    } else {
      child = Container(
        child: Text(
          'text 2',
          textDirection: TextDirection.ltr,
          key: key,
        ),
      );
    }
    return Column(
      children: <Widget>[
        RaisedButton(
          child: Text(
            '$count',
            textDirection: TextDirection.ltr,
          ),
          onPressed: () {
            print(this.widget);
            setState(() {
              count += 1;
            });
          },
        ),
        child
      ],
    );
  }
}

Element._retakeInactiveElement打个断点, 会发现Padding里的text 1Container里的text 2复用了.

GlobalKey

abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
  static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{};

  Element get _currentElement => _registry[this];
  BuildContext get currentContext => _currentElement;
  Widget get currentWidget => _currentElement?.widget;

  T get currentState {
    final Element element = _currentElement;
    if (element is StatefulElement) {
      final StatefulElement statefulElement = element;
      final State state = statefulElement.state;
      if (state is T)
        return state;
    }
    return null;
  }
  ...  
  void GlobalKey._register(Element element) {
    assert(() {
      if (_registry.containsKey(this)) {
        assert(element.widget != null);
        assert(_registry[this].widget != null);
        assert(element.widget.runtimeType != _registry[this].widget.runtimeType);
        _debugIllFatedElements.add(_registry[this]);
      }
      return true;
    }());
    _registry[this] = element;
  }
  ....
}


void Element.mount(Element parent, dynamic newSlot) {
    ...    
    if (widget.key is GlobalKey) {
        final GlobalKey key = widget.key;
        key._register(this);
    }
    ...
}

当过阅读代码我们可以知道GlobalKey其实Element被创建时就写入到一个静态Map里, 并且关联了当前的Element对象. 所以通过GlobalKey可以查询当前控件相关的信息. 下面举个例子

例子5

void main() {
  runApp(Sample5());
}

class Sample5 extends StatefulWidget {
  @override
  State createState() {
    return _Sample5State();
  }
}

class _Sample5State extends State<Sample5> {
  final key = GlobalKey<Sample5WidgetState>();

  @override
  void initState() {
    //calling the getHeight Function after the Layout is Rendered
    WidgetsBinding.instance.addPostFrameCallback((_) => getHeight());
    super.initState();
  }

  void getHeight() {
    final Sample5WidgetState state = key.currentState;
    final BuildContext context = key.currentContext;
    final RenderBox box = state.context.findRenderObject();

    print(state.number);
    print(box.size.height);
    print(context.size.height);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Sample5Widget(
          key: key,
        ),
      ),
    );
  }
}

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

  @override
  State<StatefulWidget> createState() => Sample5WidgetState();
}

class Sample5WidgetState extends State<Sample5Widget> {
  int number = 12;

  @override
  Widget build(BuildContext context) {
    return new Container(
      child: new Text(
        'text',
        style: const TextStyle(fontSize: 32.0, fontWeight: FontWeight.bold),
      ),
    );
  }
}

通过将key传递给下层, 我们在上层通过key能够获取到Sample5WidgetState对象.

除了上面所述, 那么什么时候我们需要使用key? 官方有个例子: Implement Swipe to Dismiss, 为每个item指定了key属性'Key'(不是GlobaKey). 也就是对于列表, 为了区分不同的子项, 也可能用到key. 一般key值与当前item的数据关联. 刷新时, 同一个数据指向的item复用, 不同的则无法复用.

总结

  • 对于列表可以使用key唯一关联数据.
  • GlobaKey可以让不同的页面复用视图.
  • GlobaKey可以查询节点相关信息.

执行流程先复用'widget', 不行就创建widget复用Element, 再不行就都重新创建.