Flutter中Widget之key原理探索

开始

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

官方文档介绍

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

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

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

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

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

实践

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

调用流程

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

例子1
void main() {
  runApp(Sample1());
}

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值不为空并且为GlobalKey类型时, 会判断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);
    }
    ...
}

当过阅读代码我们可以知道GlobalKeyElement被创建时就写入到一个静态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),
      ),
    );
  }
}

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

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

总结

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容

  • 本文主要介绍了Flutter布局相关的内容,对相关知识点进行了梳理,并从实际例子触发,进一步讲解该如何去进行布局。...
    Q吹个大气球Q阅读 9,590评论 6 51
  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    X先生_未知数的X阅读 15,937评论 3 118
  • 上次回来是参加小胡的婚礼,这次是珍珍 和上次相比,我好像更加冷淡了,更加平静和看淡了。 一切都要靠自己,不把自己的...
    Annie周小银阅读 167评论 0 0
  • 为什么一个人静静呆着的时候,流溢出来的都是忧伤? 《忧伤》Erwin Olaf,一组商业味十足的作品,情绪像死在牛...
    小妇阿达阅读 256评论 0 0
  • 在各种痛经症状中,比较麻烦的就是“继发性痛经”,多见于生育、流产之后或者已经人到中年,以前没有这种症状,但是不知道...
    夏霏儿阅读 491评论 1 1