FlutterのKeyを深掘りしたくない話
「Key
ってどういう時に使うんですか?」と時たま質問されるので、「Key
はそんなに使うケースがないです」って説明するためのメモをまとめます。
Key
手始めに、Key
のドキュメントを読んでみます。
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がお勧めです。
また、本zennで紹介したいと思っている内容を、別口で紹介している続編もあります。
個人的な見解として、Key
を深く理解してトリッキーな使い方をするよりも、基本的にKey
に頼らない実装をするべきです。
これは動画の最後で紹介されているような「複数のWidget間における状態の共有」はProviderやRiverpodを組み合わせた実装の方が「わかりやすい」と思う、という意味です。またFlutterの描画は十分に早く、大抵の場合Key
による最適化は早すぎる最適化に該当すると思われます。dev_toolsのPerformance view上で問題が発生していることがわかり、それがKey
により解決することが明らかな場合を除けば、利用するべきシーンも思い当たりません。
例外的なKey
の使い所としては、Form
のGlobalKey<FormState>
やgo_routerのShellRoute
があります。
ただ、これらはドキュメント通りの実装をするべきケースになります。ドキュメントに記載されている情報が不十分な場合でも、独自で頑張らずに、ドキュメントの何がわからないのかを確認した方が良いと思われます。
Keyの使いどころ
以上を踏まえたうえで、Key
の使い所を考えていきます。
LocalKey
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.
典型的には、Row
やColumn
などの複数の子要素(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
クラスの==
判定が利用されます。
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になります。
ObjectKey
はValueKey
の==
判定を、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()の実装は省略
}
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の使い分け
筆者の見解としては、LocalKey
はValueKey
のみの利用が最も安全だと思います。
また、ValueKey
のtypeをStringかintに絞る、つまりValueKey<String>
かValueKey<int>
のみ利用する運用で良いはずです。
ObjectKey
ObjectKey
はfreezedやequatableを利用することで、ValueKey
に統一できます。万が一振る舞いが期待通り出なかった場合でも、ValueKey
であればdartのコードを読むだけになるため、解析もしやすくなります。このため、かつてはObjectKey
を利用した方が良かったシーンにおいても、2023年5月現在では利用する必要がなくなっていると思います。
UniqueKey
UniqueKey
を利用したくなるシーンでは、少しだけ検討を重ねてValueKey
を利用するべきだと思います。UniqueKey
は利用できるがValueKey
は利用できないシーンというものはあまり想定できません。このため、あえてアプリケーション内にUniqueKey
とValueKey
を混在させるよりも、ValueKey
のみの利用とした方がシンプルに考えられるようになると思います。
ValueKey
のジェネリクスで絞る型
ValueKey
のジェネリクスで絞る型については、そこまで気にする必要もありませんが、「==
の判定に問題がないこと」と「==
の処理に時間がかかりにくいこと」が明らかなことが望ましいと言えます。この2つ条件を満たしていることが一目でわかるのは、プリミティブ型です。dartのプリミティブ型の中で、特に利用しやすいのはString
とint
でしょう。必要なケースではfreezed
を利用したクラスを使うことになりますが、大半のケースでは、String
かint
で十分だと思います。
LocalKeyの使い所
とはいえ、Key
の指定が有効なケースは実は多いのでは、とも思っています。
というのも、Riverpod
のConsumer
やConsumerWidget
はStatefulWidget
を継承しているため、意図せずStatefulWidget
をColumn
やRow
の子要素に設定していることがあるためです。これらはref.watch
をしていればProviderの更新に応じて描画が更新されているため、不都合は生じにくいと思うのですが、実はWidgetの再利用を促進できる面があるかもしれません。
GlobalKey
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
が生成するStatefulElement
がmount
される時となります。
このように、GlobalKey
はLocalKey
と求められている役割が全く異なります。ドキュメントには、次のように表現されています。
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
を利用する必要のあるシーンはほぼありません。このためGlobalObjectKeyやLabeledGlobalKeyといったサブクラスも存在しますが、どうしても必要になるまでは確認しなくて良いと思われます。
まとめ
例えばListの並べ化などでは、Key
に適切な値をセットすることで、意図通りの振る舞いをするようになることがあります。一方で、適当にKey
をセットしていると、意図しない箇所で描画が混ざってしまうこともあります。
唯一Column
やRow
の要素として、再利用したいStatefulWidget
を持つStatlessWidget
を置いている時、Key
のセットにより適切にWidgetが使いまわされるようになることがありますが…StatefulWidget
ではなくConsumerWidget
を使っていれば問題が起きないと思われます。スクロール時に描画が遅れるなど、よほどのパフォーマンスの問題がない限り、対応を検討する必要がないはずです。
可能な限りKey
を気にしなくて済む実装に取り組み、快適なFlutterライフを送りましょう!
Discussion