📚

FlutterのKeyを深掘りしたくない話

2023/05/02に公開

Keyってどういう時に使うんですか?」と時たま質問されるので、「Keyはそんなに使うケースがないです」って説明するためのメモをまとめます。

Key

手始めに、Keyのドキュメントを読んでみます。

https://api.flutter.dev/flutter/foundation/Key-class.html

A Key is an identifier for Widgets, Elements and SemanticsNodes.

A new widget will only be used to update an existing element if its key is the same as the key of the current widget associated with the element.

ドキュメントに添付されている動画では、Keyの使い所として、Todoリストやタイルの例が示されています。Keyの扱い方を把握するだけであれば、この動画の解説の範囲で十分です。
コードを確認しながら、振る舞いを十分に理解したい場合には、次のQiitaがお勧めです。

https://qiita.com/kurun_pan/items/f91228cf5c793ec3f3cc

また、本zennで紹介したいと思っている内容を、別口で紹介している続編もあります。

https://qiita.com/kurun_pan/items/0517fb62f1b47c90882c


個人的な見解として、Keyを深く理解してトリッキーな使い方をするよりも、基本的にKeyに頼らない実装をするべきです。

これは動画の最後で紹介されているような「複数のWidget間における状態の共有」はProviderRiverpodを組み合わせた実装の方が「わかりやすい」と思う、という意味です。またFlutterの描画は十分に早く、大抵の場合Keyによる最適化は早すぎる最適化に該当すると思われます。dev_toolsのPerformance view上で問題が発生していることがわかり、それがKeyにより解決することが明らかな場合を除けば、利用するべきシーンも思い当たりません。

例外的なKeyの使い所としては、FormGlobalKey<FormState>go_routerShellRouteがあります。
ただ、これらはドキュメント通りの実装をするべきケースになります。ドキュメントに記載されている情報が不十分な場合でも、独自で頑張らずに、ドキュメントの何がわからないのかを確認した方が良いと思われます。

Keyの使いどころ

以上を踏まえたうえで、Keyの使い所を考えていきます。

LocalKey

https://api.flutter.dev/flutter/foundation/LocalKey-class.html

A key that is not a GlobalKey.

Keys must be unique amongst the Elements with the same parent. By contrast, GlobalKeys must be unique across the entire app.

典型的には、RowColumnなどの複数の子要素(children)をもつWidgetの子要素に指定します。

LocalKeyはabstract classとなり、継承したクラスとしてObjectKey/UniqueKey/ValueKeyの3つをもちます。
Key('hoge')のような記述をすると、下記のような実装になっているため、LocalKeyを継承したValueKeyを利用しています。


abstract class Key {
  /// Construct a [ValueKey<String>] with the given [String].
  ///
  /// This is the simplest way to create keys.
  const factory Key(String value) = ValueKey<String>;

  /// Default constructor, used by subclasses.
  ///
  /// Useful so that subclasses can call us, because the [Key.new] factory
  /// constructor shadows the implicit constructor.
  
  const Key.empty();
}

ValueKeyの実装を見てみると、このクラスの目的はよりわかりやすくなります。
型が同一かどうかを文字列で比較し、同じ場合、値を比較するだけです。Stringを利用している場合、この判定がシンプルに解決されることがわかります。

class ValueKey<T> extends LocalKey {
  /// Creates a key that delegates its [operator==] to the given value.
  const ValueKey(this.value);

  /// The value to which this key delegates its [operator==]
  final T value;

  
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is ValueKey<T>
        && other.value == value;
  }

  
  int get hashCode => Object.hash(runtimeType, value);

  /// toString()の実装は省略
}

続いてUniqueKeyの実装を見てみると、違いがよくわかります。

class UniqueKey extends LocalKey {
  /// Creates a key that is equal only to itself.
  ///
  /// The key cannot be created with a const constructor because that implies
  /// that all instantiated keys would be the same instance and therefore not
  /// be unique.
  // ignore: prefer_const_constructors_in_immutables , never use const for this class
  UniqueKey();

  /// toString()の実装は省略
}

UniqueKeyは、一致の判定をoverrideしていません。暗黙的にObjectクラスの==判定が利用されます。

https://api.flutter.dev/flutter/dart-core/Object/operator_equals.html

The default behavior for all Objects is to return true if and only if this object and other are the same object.

ドキュメントの通り、同一のインスタンスでない限りtrueとはなりません。final uniqueKey = UniqueKey();のように保持し、複数のWidgetにセットしない限り、同一とは判定されない仕組みとなります。
従って、UniqueKeyは運用上必ずuniqueになります。


ObjectKeyValueKey==判定を、dart coreのidenticalに任せる実装になります。

class ObjectKey extends LocalKey {
  /// Creates a key that uses [identical] on [value] for its [operator==].
  const ObjectKey(this.value);

  /// The object whose identity is used by this key's [operator==].
  final Object? value;

  
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is ObjectKey
        && identical(other.value, value);
  }

  
  int get hashCode => Object.hash(runtimeType, identityHashCode(value));

  /// toString()の実装は省略
}

https://api.dart.dev/stable/2.19.6/dart-core/identical.html

identicalメソッドは「直感に反するも正しい」動作をすることがあります。ドキュメントに記載されている範囲ですと、下記の箇所です。
このためObjectKeyを利用する場合には、意図した動作がなされているかをみる必要があります。

isIdentical = identical(const Object(), const Object()); // true, const canonicalizes
isIdentical = identical([1], [1]); // false
isIdentical = identical(const [1], const [1]); // true
isIdentical = identical(const [1], const [2]); // false
isIdentical = identical(2, 1 + 1); // true, integers canonicalizes

LocalKeyの使い分け

筆者の見解としては、LocalKeyValueKeyのみの利用が最も安全だと思います。
また、ValueKeyのtypeをStringかintに絞る、つまりValueKey<String>ValueKey<int>のみ利用する運用で良いはずです。

ObjectKey

ObjectKeyfreezedequatableを利用することで、ValueKeyに統一できます。万が一振る舞いが期待通り出なかった場合でも、ValueKeyであればdartのコードを読むだけになるため、解析もしやすくなります。このため、かつてはObjectKeyを利用した方が良かったシーンにおいても、2023年5月現在では利用する必要がなくなっていると思います。

UniqueKey

UniqueKeyを利用したくなるシーンでは、少しだけ検討を重ねてValueKeyを利用するべきだと思います。UniqueKeyは利用できるがValueKeyは利用できないシーンというものはあまり想定できません。このため、あえてアプリケーション内にUniqueKeyValueKeyを混在させるよりも、ValueKeyのみの利用とした方がシンプルに考えられるようになると思います。

ValueKeyのジェネリクスで絞る型

ValueKeyのジェネリクスで絞る型については、そこまで気にする必要もありませんが、「==の判定に問題がないこと」と「==の処理に時間がかかりにくいこと」が明らかなことが望ましいと言えます。この2つ条件を満たしていることが一目でわかるのは、プリミティブ型です。dartのプリミティブ型の中で、特に利用しやすいのはStringintでしょう。必要なケースではfreezedを利用したクラスを使うことになりますが、大半のケースでは、Stringintで十分だと思います。

LocalKeyの使い所

とはいえ、Keyの指定が有効なケースは実は多いのでは、とも思っています。
というのも、RiverpodConsumerConsumerWidgetStatefulWidgetを継承しているため、意図せずStatefulWidgetColumnRowの子要素に設定していることがあるためです。これらはref.watchをしていればProviderの更新に応じて描画が更新されているため、不都合は生じにくいと思うのですが、実はWidgetの再利用を促進できる面があるかもしれません。

GlobalKey

https://api.flutter.dev/flutter/widgets/GlobalKey-class.html

GlobalKeyは、アプリケーション内でuniqueなことが求められるKeyです。クラスの定義を見るとわかる通り、currentStateにてState<StatefulWidget>を返す実装となります。


abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
  /// Creates a [LabeledGlobalKey], which is a [GlobalKey] with a label used for
  /// debugging.
  ///
  /// The label is purely for debugging and not used for comparing the identity
  /// of the key.
  factory GlobalKey({ String? debugLabel }) => LabeledGlobalKey<T>(debugLabel);

  /// Creates a global key without a label.
  ///
  /// Used by subclasses because the factory constructor shadows the implicit
  /// constructor.
  const GlobalKey.constructor() : super.empty();

  Element? get _currentElement => WidgetsBinding.instance.buildOwner!._globalKeyRegistry[this];

  /// The build context in which the widget with this key builds.
  ///
  /// The current context is null if there is no widget in the tree that matches
  /// this global key.
  BuildContext? get currentContext => _currentElement;

  /// The widget in the tree that currently has this global key.
  ///
  /// The current widget is null if there is no widget in the tree that matches
  /// this global key.
  Widget? get currentWidget => _currentElement?.widget;

  /// The [State] for the widget in the tree that currently has this global key.
  ///
  /// The current state is null if (1) there is no widget in the tree that
  /// matches this global key, (2) that widget is not a [StatefulWidget], or the
  /// associated [State] object is not a subtype of `T`.
  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;
  }
}

/// A global key with a debugging label.
///
/// The debug label is useful for documentation and for debugging. The label
/// does not affect the key's identity.

class LabeledGlobalKey<T extends State<StatefulWidget>> extends GlobalKey<T> {
  /// Creates a global key with a debugging label.
  ///
  /// The label does not affect the key's identity.
  // ignore: prefer_const_constructors_in_immutables , never use const for this class
  LabeledGlobalKey(this._debugLabel) : super.constructor();

  final String? _debugLabel;

  /// toString()の実装は省略
}

WidgetsBinding.instance.buildOwner!._globalKeyRegistry[this]はBuildOwnerのfinal Map<GlobalKey, Element> _globalKeyRegistry = <GlobalKey, Element>{};にアクセスしています。
先述の通り、Key==を利用しているため、同一のGlobalKeyでのみElementを取得できる処理です。ElementがputされるのはElementクラスのmountメソッド、ざっくりいうとStatefulWidgetが生成するStatefulElementmountされる時となります。
このように、GlobalKeyLocalKeyと求められている役割が全く異なります。ドキュメントには、次のように表現されています。

Global keys provide access to other objects that are associated with those elements, such as BuildContext. For StatefulWidgets, global keys also provide access to State.


GlobalKeyを利用する必要があるシーンは限られてきます。具体的には、FlutterのWidget(Form)や別のライブラリに依存できないライブラリ(go_router)などです。
通常の開発においては、GlobalKeyを利用する必要のあるシーンはほぼありません。このためGlobalObjectKeyLabeledGlobalKeyといったサブクラスも存在しますが、どうしても必要になるまでは確認しなくて良いと思われます。

まとめ

例えばListの並べ化などでは、Keyに適切な値をセットすることで、意図通りの振る舞いをするようになることがあります。一方で、適当にKeyをセットしていると、意図しない箇所で描画が混ざってしまうこともあります。
唯一ColumnRowの要素として、再利用したいStatefulWidgetを持つStatlessWidgetを置いている時、Keyのセットにより適切にWidgetが使いまわされるようになることがありますが……StatefulWidgetではなくConsumerWidgetを使っていれば問題が起きないと思われます。スクロール時に描画が遅れるなど、よほどのパフォーマンスの問題がない限り、対応を検討する必要がないはずです。

可能な限りKeyを気にしなくて済む実装に取り組み、快適なFlutterライフを送りましょう!

GitHubで編集を提案

Discussion