【Flutter】TextFieldにフォーカスしてキーボードが立ち上がった瞬間にクラッシュするエラーに対処した話

Devlog
Devlog

問題発生

エラーはこんな感じ。(ユーザー情報設定は遷移と同時にフォーカスが当たる設定にしているので、即座にクラッシュしてます。)Androidエミュレータ、iOSエミュレータ、Android実機全てで同様のエラーが発生します。またエミュレータは機種問わず発生。

Flutterの最新版にアップデートした際に発生したのでバージョンを下げてビルドし直したものの直らず、沼にハマってしまいました•••

吐いたエラーの解析

// 1.ソフトキーボードの表示要求
// showSoftInput() が呼び出され、Flutterの入力ビュー(FlutterView)にソフトキーボードが表示されようとしている
[+24535 ms] I/ImeTracker(14722): com.hami030.dayby:84539269: onRequestShow at ORIGIN_CLIENT reason SHOW_SOFT_INPUT fromUser false
[   +2 ms] D/InputMethodManager(14722): showSoftInput() view=io.flutter.embedding.android.FlutterView{c39c4fa VFE...... .F...... 0,0-1440,3120 #1 aid=1073741824} flags=0 reason=SHOW_SOFT_INPUT

// 2.MESA関連のログ
// MESAはOpenGLのレンダリング関連のモジュール
// FlutterのハードウェアアクセラレーションやUIの描画処理に関連している
[  +47 ms] I/MESA    (14722): exportSyncFdForQSRILocked: call for image 0xb400007bf00d58d0 hos timage handle 0x7000600001400
[   +2 ms] I/MESA    (14722): exportSyncFdForQSRILocked: got fd: 366

[  +17 ms] D/InputConnectionAdaptor(14722): The input method toggled cursor monitoring on
[ +202 ms] I/MESA    (14722): exportSyncFdForQSRILocked: call for image 0xb400007bf0093350 hos timage handle 0x7000600001401
[  +20 ms] I/MESA    (14722): exportSyncFdForQSRILocked: got fd: 315
[ +102 ms] D/InsetsController(14722): show(ime(), fromIme=true)
[ +109 ms] D/EGL_emulation(14722): app_time_stats: avg=39442.46ms min=65.27ms max=118133.17ms count=3
[  +22 ms] I/MESA    (14722): exportSyncFdForQSRILocked: call for image 0xb400007bf00dce50 hos timage handle 0x7000600001402
[   +6 ms] I/MESA    (14722): exportSyncFdForQSRILocked: got fd: 340

// 3.ソフトキーボードが実際に表示される
[ +259 ms] I/ImeTracker(14722): com.hami030.dayby:84539269: onShown
[  +18 ms] I/MESA    (14722): exportSyncFdForQSRILocked: call for image 0xb400007bf00dd0d0 hos timage handle 0x70006000013ff
[   +2 ms] I/MESA    (14722): exportSyncFdForQSRILocked: got fd: 405

// 4.すぐにソフトキーボードが非表示にされる
// hideSoftInput() が呼ばれてキーボードが非表示になった
// fromUser false なので、ユーザーが手動でキーボードを閉じたわけではない。アプリの内部処理で hideSoftInput() を呼び出している可能性がある
[  +50 ms] I/ImeTracker(14722): com.hami030.dayby:23e43d81: onRequestHide at ORIGIN_CLIENT reason HIDE_SOFT_INPUT fromUser false
[   +2 ms] D/InputConnectionAdaptor(14722): The input method toggled cursor monitoring off
[   +4 ms] D/InsetsController(14722): hide(ime(), fromIme=true)

// 5.WindowOnBackDispatcherによるコールバック
// キーボードが非表示になる際に onBackPressed が発生し、それがキャンセルされている
// Flutter  UI ツリーに何らかの影響を与えている可能性あり
[  +19 ms] W/WindowOnBackDispatcher(14722): sendCancelIfRunning: isInProgress=false callback=ImeCallback=ImeOnBackInvokedCallback@157874936 Callback=android.window.IOnBackInvokedCallback$Stub$Proxy@bd68b99


Crashlyticsにも実機のエラーが上がってました。(エミュレータのクラッシュは上がってなかったです🤔)
描画サイズ的なエラーの模様。

PlatformException(error, Attempt to write to field 'int android.view.ViewGroup$LayoutParams.width' on a null object reference in method 'void io.flutter.plugin.platform.n.i(e4.f, e4.d)', null, java.lang.NullPointerException: Attempt to write to field 'int android.view.ViewGroup$LayoutParams.width' on a null object reference in method 'void io.flutter.plugin.platform.n.i(e4.f, e4.d)' at io.flutter.plugin.platform.n.i(SourceFile:321) at y0.o.m(SourceFile:1194) at S0.g.c(SourceFile:28) at X3.b.run(SourceFile:140) at android.os.Handler.handleCallback(Handler.java:958) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loopOnce(Looper.java:224) at android.os.Looper.loop(Looper.java:318) at android.app.ActivityThread.main(ActivityThread.java:8765) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:561) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1013) )

原因

ソースコード全体を順にコメントアウトしながらデバッグしたところ、テキストサイズを制御しているコードが原因と判明。ネイティブコードを確認して回ったり、全ページを順に接続して検証したり、パッケージをひとつずつ抜いてみたりと、特定までかなり苦労しました•••

問題のあったコードはこちら

Dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:electricorange/infrastructure/remote_config/remote_config.dart';
import 'package:electricorange/presentation/styles/sizes.dart';
import 'package:electricorange/presentation/styles/texts.dart';
import 'package:electricorange/presentation/styles/theme.dart';
import 'package:electricorange/presentation/widgets/mainte_page.dart';
import 'package:electricorange/firebase_options.dart';

void main() {
  runZonedGuarded<Future<void>>(
    () async {
      // Firebaseの初期化
      WidgetsFlutterBinding.ensureInitialized();
      await MobileAds.instance.initialize();
      await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform,
      );
      await RemoteConfigService().setRemoteConfig();
      // 画面の向き
      await SystemChrome.setPreferredOrientations([
        // 縦向き
        DeviceOrientation.portraitUp,
        DeviceOrientation.portraitDown,
      ]);
      // Flutterフレームワーク内でスローされたすべてのエラーを自動的にキャッチ
      FlutterError.onError =
          FirebaseCrashlytics.instance.recordFlutterFatalError;
      runApp(
        ProviderScope(
          child: ScreenUtilInit(
            designSize: Size(411, 890),
            builder: (context, child) {
              return child!;
            },
            child: MyApp(),
          ),
        ),
      );
    },
    // Flutterフレームワーク内でキャッチされないエラー
    (error, stack) async => await FirebaseCrashlytics.instance.recordError(
      error,
      stack,
      fatal: true,
    ),
  );
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // myRouterの継承は割愛
    
    final app = MaterialApp.router(
      routeInformationProvider: router.routeInformationProvider,
      routeInformationParser: router.routeInformationParser,
      routerDelegate: router.routerDelegate,
      localizationsDelegates: const [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      theme: ThemeData(
        // 割愛
      ),
      supportedLocales: const [
        // Locale("en"),
        Locale("ja"),
      ],
      debugShowCheckedModeBanner: false, // タグ非表示
    );

    return MediaQuery(
      data: MediaQuery.of(context).copyWith(
        textScaler: TextScaler.linear(1),
        boldText: false,
      ), // 文字サイズを固定
      child: app,
    );
  }
}

どういう不具合なのか、この部分のコードの追記をしてから複数の解像度のシミュレータでUIテストをしていたのですが、追記後しばらくはキーボード立ち上げクラッシュ問題が起こらず普通に使えていたのですよね。追記後にすぐエラーが起きてくれれば簡単に特定できたものを•••

ちなみにこちらも同じような事象に見えますね。
https://github.com/flutter/flutter/issues/145262

解決方法

色々記載箇所を調整したのでこれ以外でも解決するかもしれませんが、たどり着いたコードはこちら。

  • ScreenUtilInitMyApp をラップ(これは変更前と実質変わらない気がする)
  • MediaQuery(data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1),boldText: false,•••MaterialApp をラップしていたのを、逆のラッピング構造に
Dart
void main() {
  runZonedGuarded<Future<void>>(
    () async {
      // 割愛
      runApp(ProviderScope(child: MyApp()));
    },
    // 割愛
  );
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    return ScreenUtilInit(
      designSize: const Size(411, 890), // 基準サイズ
      minTextAdapt: true, // テキストサイズの適応を有効化
      splitScreenMode: true, // マルチウィンドウ対応
      builder: (context, child) {
        return MaterialApp.router(
          // 割愛
          builder: (context, child) => MediaQuery(
            data: MediaQuery.of(context).copyWith(
              textScaler: TextScaler.linear(1),
              boldText: false,
            ), // 文字サイズを固定
            child: child!,
          ),
        );
      },
    );
  }
}

恐らく、ネイティブのレイヤーで描画されるキーボードを呼び出す際に、Flutterアプリの描画(MaterialApp)の領域外へ遷移し、TextScaler が悪さをしていたのでしょうか。あまりメカニズムを解明できず申し訳ないですが、とりあえず完全に解決したのでめでたし!難しいね!!

コメント

タイトルとURLをコピーしました