KabologHomeXGitHub

メソッドに渡すときにawaitするのは危険

Dart Advent Calendar 2022 の 24 日目の記事です。

先日の 非同期処理シリーズ のどれかに含めようと思いながら忘れていたことを書きます。
ちょっとしたことで期待する動作と違ってしまうので気をつけましょうという話です。

基礎知識

Flutter で copyWith() というメソッドを使うのはよくあるパターンです。
一部のプロパティの値だけを変更して新たなオブジェクトを作るときに使います。

例)profile と imageUrl を持つ User クラス

class User {  
  const User({  
    this.profile = const Profile(),  
    this.imageUrl = '',  
  });  
  
  final Profile profile;  
  final String imageUrl;  
  
  User copyWith({Profile? profile, String? imageUrl}) {  
    return User(  
      profile: profile ?? this.profile,  
      imageUrl: imageUrl ?? this.imageUrl,  
    );  
  }  
}  

imageUrl だけを変更して新たな User を作るには次のようにします。

final newUser = oldUser.copyWith(  
  imageUrl: 'https://example.com/image.jpg',  
);  

再現コード

では copyWith() で起こる問題を見ましょう。

UserNotifier には _fetchProfile()_fetchImageUrl() があり、その二つを Future.wait() によって並行して実行するようになっています。

class UserNotifier extends ValueNotifier<User> {  
  UserNotifier() : super(const User());  
  
  Future<void> fetchUser({required String id}) async {  
    await Future.wait([  
      _fetchProfile(id),  
      _fetchImageUrl(id),  
    ]);  
  }  
  
  Future<void> _fetchProfile(String id) async {  
    final profile = await repository.fetchProfile(id);  
    value = value.copyWith(profile: profile);  
  }  
  
  Future<void> _fetchImageUrl(String id) async {  
    value = value.copyWith(  
      imageUrl: await repository._fetchImageUrl(id),  
    );  
  }  
}  

起こること

fetchImageUrl() のほうが遅く終わる場合に fetchProfile() による value の更新がかき消されてしまいます。
取得処理を記述している位置が異なることが関連しています。

  • fetchProfile()
    • 取得が終わるのを待ってから copyWith() を使う
  • fetchImageUrl()
    • copyWith() の引数のところで取得処理を実行して終わるまで待つ

ここからはそれぞれを A、B と呼びます。

原因を探る

ステップ実行

ブレークポイントを置いてステップ実行してみました。

  • 1)A で取得を開始
  • 2)B で copyWith() を呼ぶ
  • 3)B で取得を開始
  • 4)A での取得が先に終わる
  • 5)A で copyWith() を呼ぶ
  • 6)copyWith() の中身を実行
  • 7)A が完了
  • 8)B での取得も終わる
  • 9)copyWith() の中身を実行
  • 10)B が完了

6 と 9 はそれぞれ A と B の取得完了に伴うものなので、順番に value が更新されて期待通りに動きそうに思えます。

各ステップにおけるデータ

次に、ステップ実行時に IntelliJ 上に表示されるデータを確認してみました。
関連するデータは次のようになっているとします。

  • User の id: 'abcde'
  • A で取得するデータ: Profile(name: 'Mike', age: 24)
  • B で取得するデータ: 'https://example.com/image.jpg'

2)B で copyWith() を呼ぶ

this = {UserNotifier} UserNotifier#f2826(User#1bf41(profile: Profile(name: , age: 0), imageUrl: ))  
id = "abcde"  

6)A から呼ばれた copyWith() の中身を実行

this = {User} User#1bf41(profile: Profile(name: , age: 0), imageUrl: )  
profile = {Profile} Profile(name: Mike, age: 24)  
imageUrl = null  

7)A が完了

this = {UserNotifier} UserNotifier#f2826(User#816a8(profile: Profile(name: Mike, age: 24), imageUrl: ))  
id = "abcde"  
profile = {Profile} Profile(name: Mike, age: 24)  

9)B から呼ばれた copyWith() の中身を実行

this = {User} User#1bf41(profile: Profile(name: , age: 0), imageUrl: )  
profile = null  
imageUrl = "https://example.com/image.jpg"  

10)B が完了

this = {UserNotifier} UserNotifier#f2826(User#e1bf0(profile: Profile(name: , age: 0), imageUrl: https://example.com/image.jpg))  
id = "abcde"  

User の profile は 7 の時点でセットされたのに 9 で初期値になっています。
また、User のハッシュコードが 7 で #816a8 に変わったのに 9 では 2 の時点と同じ #1bf41 です。
これで状況がわかりました。

補足

UserNotifier は Flutter のフレームワークが提供する ValueNotifier を継承しているので、デバッガや print() の出力に自動的にハッシュコードが含まれます。
一方、User では自分で toString() をオーバーライドして含めないと確認できません。

原因

  • 2)B で copyWith() を使って User インスタンスを作り直して value に入れようとする(未完)
  • 6)A で同様のことを行う(こちらはここで完了し、value が新しいインスタンスになる)
  • 9)2 での呼ばれた copyWith() の中身はここでようやく実行される

2 の copyWith() 呼び出しに使っている User は 6 で作り直される前のインスタンスなので、その呼び出しによる 9 の実行時には A での取得結果がないことになります。

まとめ

copyWith() を使うときには引数のところで await しないように注意しましょう。
copyWith() でなくても渡す前に Future を完了させるよう意識しておくのが良いかもしれません。

Xに投稿する