Start Debugging

修正: システムのフォントスケーリングが有効なとき、Android WebView で Flutter の Text が画面外に描画される

Flutter web 3.41 から 3.44 では、Android WebView の textZoom が 100 以外のとき、約 625 倍の行の高さのオーバーライドが報告されます。3.47 にアップグレードするか、MaterialApp.builder でオーバーライドを消去してください。

Flutter web アプリを Android の WebView 内で実行していて、ユーザーがシステムのフォントサイズを変更した途端にすべての通常の Text が消えてしまう場合、これは web エンジンの既知のバグです。Flutter 3.41.0 から 3.44.9 までは、WebView の textZoom をユーザーの行の高さの設定として誤って読み取ります。MediaQuery.lineHeightScaleFactorOverride がおよそ 624.9 を返すため、18 px の行は約 12,900 px の高さでレイアウトされ、グリフはビューポートのはるか下に描画されます。検出コードが書き直された Flutter 3.47.0 以降 (現在の stable は 3.47.3) にアップグレードしてください。古いバージョンでは、MaterialApp.builder で誤ったオーバーライドを消去します。Android ホストを自分で管理している場合は、textZoom を 100 に固定することもできます。

この記事では、バグ報告に記載されたバージョンである Flutter 3.44.8 (Dart 3.12.2) を対象とし、3.47.3 のエンジンソースと比較します。以下のウィジェットレベルの挙動は、3.44.8 上で flutter test を使って再現しました。

壊れた画面はどのように見えるか

例外もコンソールエラーもありません。Web フォントは HTTP 200 で読み込まれ、flutter-first-frame イベントも発火し、スケジューラーも動き続けます。症状はすべて幾何学的なものです。

発生条件は限定的です。同じビルドでも、デスクトップの Chrome、同じスマートフォンの Chrome ブラウザアプリ、GeckoView、デフォルト設定の標準エミュレーター上の WebView では正常に描画されます。壊れるのは、textZoom がちょうど 100 ではない Android System WebView だけです。設定 > ユーザー補助 にあるシステムのフォントサイズのスライダーは、この値をオーバーライドしていないすべての WebView に対して自動的にこの値を設定します。

アプリ内から MediaQuery の値を出力すると、原因は明らかです。flutter/flutter#190350 では、報告者が Flutter 3.44.8 で標準の flutter create アプリを実行し、adb shell settings put system font_scale だけを変更しました。

font_scale  textZoom  lineHeightScaleFactorOverride  textScaler  Text visible
0.85        85        624.9374824709756              0.85        no
1.0         100       null                           1.0         yes
1.15        115       624.9347955648752              1.15        no
1.3         130       624.9375229225718              1.3         no

textScaler はどの段階でも正しい値です。lineHeightScaleFactorOverride は 100 のときは null で、それ以外のズームレベルでは、テキストが小さくなっても大きくなっても約 624.94 です。入力がどちらの方向に動いても変わらない値は、測定値ではありません。センチネル値が漏れ出しているのです。

web エンジンが 625 倍の行の高さを報告する理由

Flutter 3.41 以降、web エンジンはブラウザ拡張機能やユーザースタイルシートが適用する WCAG 1.4.12 のテキストの間隔 の設定をサポートしています。これを追加したのは PR #178081 です。Flutter はテキストをキャンバスに描画するため、自身のコンテンツからこれらの CSS オーバーライドを読み取れません。その代わりに、EnginePlatformDispatcher._addTypographySettingsObserver は、意図的にありえない値のインラインスタイルを持つ非表示の <p> プローブ要素を document.body に追加し、ResizeObserver で監視します。3.44.8 では、engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart の該当部分は次のようになっています。

// Flutter 3.44.8, lib/web_ui/lib/src/engine/platform_dispatcher.dart (abridged)
const spacingDefault = 9999.0;
_typographyMeasurementElement!.style
  ..lineHeight = '${spacingDefault}px'
  ..letterSpacing = '${spacingDefault}px'
  ..wordSpacing = '${spacingDefault}px'
  ..margin = '0px 0px ${spacingDefault}px 0px';
domDocument.body!.append(_typographyMeasurementElement!);
final double typographyMeasurementElementFontSize =
    parseFontSize(_typographyMeasurementElement!)?.toDouble() ?? _defaultRootFontSize;
final double defaultLineHeightFactor = spacingDefault / typographyMeasurementElementFontSize;

// Inside the ResizeObserver callback:
final double? computedLineHeightScaleFactor =
    fontSize != null && lineHeight != null && lineHeight != spacingDefault
    ? lineHeight / fontSize
    : null;
_updateLineHeightScaleFactorOverride(
  computedLineHeightScaleFactor == defaultLineHeightFactor
      ? null
      : computedLineHeightScaleFactor,
);

考え方としては、Flutter の外部の何もプローブに触れていなければ、計算された line-height はちょうど 9999px のままで、オーバーライドは null のままになる、というものです。それ以外の値はユーザーの設定を意味し、フォントサイズに対する比率が新しい行の高さの係数になります。

Android WebView の textZoom は、この両方のチェックを壊します。ルートの font-size をスケーリングするだけでなく、プローブのピクセル単位の line-height もスケーリングしますが、これはまったくユーザーの設定ではありません。textZoom が 115 で、ルートのデフォルトサイズが 16 px の場合を計算してみます。

  1. プローブのフォントサイズは 16 * 1.15 = 18.4px なので、defaultLineHeightFactor = 9999 / 18.4 = 543.4 です。
  2. 計算された line-height9999 * 1.15 = 11498.85px です。これは 9999 ではないため、エンジンはオーバーライドとして扱います。
  3. computedLineHeightScaleFactor = 11498.85 / 18.4 = 624.9375 で、これはちょうど 9999 / 16 です。ズーム係数が打ち消し合うため、報告される値はズームレベルが変わってもほとんど変化しません。
  4. 624.9375543.4 と等しくないため、MediaQueryData.lineHeightScaleFactorOverride として公開されます。

フレームワークはこの値をそのまま受け取ります。Text.buildMediaQuery.maybeLineHeightScaleFactorOverrideOf(context) を読み取り、inherit に関係なく、スパンの TextStyle.height に、そして strut が設定されている場合は StrutStyle.height にも強制的に適用します。leadingDistribution の記事 で説明したとおり、TextStyle.height はフォントサイズに対する倍率なので、行ボックスはグリフの 625 倍の高さになります。Icon のように RichText を直接構築するウィジェットは、このオーバーライドを参照しません。そのため、テキストがすべて消えた画面でもアイコンは残るのです。

これは以前のバグの亜種です。#178856 では、実行時にブラウザのフォントサイズを変更した後に同じ異常な値が出ることが報告され、PR #178862 が 2025-12-02 にそれを修正しました。この修正は依然として 9999 との完全一致で比較していたため、最初の描画の時点ですでに有効なズームは素通りしてしまいます。issue スレッドでの bisect により、リグレッションは 3.39.0-0.2.pre (正常) と 3.40.0-0.1.pre (異常) の間にあると特定されています。stable リリースの中では、9999 px のセンチネルは 3.41.0 から 3.44.9 までのすべてのタグに存在し、3.38.x には存在しません。

レンダラーは関係ありません。スレッドでは、CanvasKit、CPU を強制した CanvasKit、そして flutter build web --wasm ビルドの skwasm でバグが再現されています。欠陥が共有の Dart コードにあるためです。

最小限の再現コード

web 側は、オーバーライドを RichText で表示するだけの標準的なアプリです。こうすることで、バグが発生している間もレポートを読める状態に保てます。

// Flutter 3.44.8, web target. Serve build/web and load it in an Android WebView.
import 'package:flutter/material.dart';

void main() => runApp(
      const MaterialApp(home: Scaffold(body: SafeArea(child: Probe()))),
    );

class Probe extends StatelessWidget {
  const Probe({super.key});

  @override
  Widget build(BuildContext context) {
    final mq = MediaQuery.of(context);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        RichText(
          text: TextSpan(
            text: 'line=${mq.lineHeightScaleFactorOverride}\n'
                'scale10=${mq.textScaler.scale(10)}',
            style: const TextStyle(fontSize: 15, color: Colors.black),
          ),
        ),
        const Text('PLAIN TEXT', style: TextStyle(fontSize: 18, color: Colors.red)),
      ],
    );
  }
}

flutter build web --release でビルドし、build/web を配信します。次に adb shell settings put system font_scale 1.15 を実行し、JavaScript を有効にした素の android.webkit.WebView でページを開きます。WebView はシステムのスケールを自動的に取り込むため、ホストが setTextZoom を呼び出す必要はありません。

デバイスがない場合でも、エンジンが報告する値を与えることで、フレームワーク側の半分をウィジェットテストで再現できます。

// Flutter 3.44.8, flutter_test
testWidgets('engine-reported override inflates Text', (tester) async {
  await tester.pumpWidget(MaterialApp(
    builder: (context, child) => MediaQuery(
      data: MediaQuery.of(context)
          .copyWith(textScaler: const TextScaler.linear(1.15))
          .applyTextStyleOverrides(
            lineHeightScaleFactorOverride: 624.9375,
            letterSpacingOverride: null,
            wordSpacingOverride: null,
            paragraphSpacingOverride: null,
          ),
      child: child!,
    ),
    home: const Scaffold(
      body: SingleChildScrollView(
        child: Text('plain', key: Key('t'), style: TextStyle(fontSize: 18)),
      ),
    ),
  ));
  debugPrint('${tester.getSize(find.byKey(const Key('t'))).height}');
});

3.44.8 ではこれが 12936.0 を出力します。これは、issue の報告者が実際の WebView で測定した 12,936 px の行の高さと同じです。

修正 1: Flutter 3.47 にアップグレードする

2026-05-19 にマージされた PR #186474 が、プローブのロジックを書き直しました。これは、同じ仕組みで同じ膨れ上がった係数を生み出していた Safari の “never use font sizes smaller than” のバグ (#185931) のために書かれたものです。この修正は 3.46.0-0.1.pre で初めて出荷され、すべての 3.47 stable リリースに含まれています。2026-08-05 の 3.44.9 を含む 3.44.x のホットフィックス系列には、一度も取り込まれていません。新しいコードは次のとおりです。

// Flutter 3.47.3, lib/web_ui/lib/src/engine/platform_dispatcher.dart (abridged)
const spacingDefault = 100.0;
final double defaultLineHeightFactor =
    spacingDefault / (typographyMeasurementElementFontSize / findBrowserTextScaleFactor());

bool isDefault(double? value, double defaultValue) {
  if (value == null) {
    return true;
  }
  return (value - defaultValue).abs() < _typographyPrecisionErrorTolerance ||
      (value - defaultValue * computedTextScaleFactor).abs() <
          _typographyPrecisionErrorTolerance;
}

findBrowserTextScaleFactor() はルートのフォントサイズを 16 で割った値で、textZoom が 115 のときは 1.15 になります。100 * 1.15 というズームされた行の高さは “デフォルト” として扱われるようになり、ズームされた文字間隔、単語間隔、段落のマージンも同様です。textScaler は引き続き 1.15 を報告しつつ、オーバーライドは null のままになります。センチネルも 9999 px から 100 px に下がったため、将来誤検出が起きても、係数は 625 ではなく約 6 になります。

注意点が 1 つあります。#190350 はまだオープンのままで、スレッドでは誰も 3.47 でのデバイステストを投稿していません。上記の分析はソースを読んだ結果であり、WebView で実行した結果ではありません。アップグレード後は、実機で font_scale 1.15 にして RichText プローブを実行し、回避策を削除する前に line=null になることを確認してください。いずれにせよ 3.44 からアップグレードするのであれば、アプリの他のターゲットについて 3.47 のデスクトップレンダラーの変更 も読んでおく価値があります。

flutter upgrade
flutter --version

修正 2: MaterialApp.builder でありえないオーバーライドを消去する

まだ 3.41 から 3.44 を離れられない場合や、ホストアプリを管理していない場合は、ウィジェットツリーのルートで修正します。MediaQuery.applyTextStyleOverrides は、その配下のすべてに対して 4 つの間隔のオーバーライドを置き換えます。それぞれを渡した値 (null を含む) そのものに設定し、textScaler は維持するため、ユーザーが選んだフォントサイズは引き続き適用されます。

issue に投稿された回避策では、4 つすべてを null に設定しています。これでも動作しますが、プローブの存在理由である本物の WCAG のテキスト間隔の設定まで捨ててしまいます。より絞り込んだガードでは、実際のユーザー設定では生じ得ない値だけを破棄します。

// Flutter 3.41.0 to 3.44.9, workaround for flutter/flutter#190350
import 'package:flutter/widgets.dart';

/// Drops text spacing overrides that no real user preference can produce.
Widget sanitizeTextSpacing(BuildContext context, Widget? child) {
  final mq = MediaQuery.of(context);
  double? sane(double? value, double max) =>
      value == null || value.abs() > max ? null : value;

  final lineHeight = sane(mq.lineHeightScaleFactorOverride, 4);
  final letter = sane(mq.letterSpacingOverride, 100);
  final word = sane(mq.wordSpacingOverride, 100);
  final paragraph = sane(mq.paragraphSpacingOverride, 1000);

  if (lineHeight == mq.lineHeightScaleFactorOverride &&
      letter == mq.letterSpacingOverride &&
      word == mq.wordSpacingOverride &&
      paragraph == mq.paragraphSpacingOverride) {
    return child ?? const SizedBox.shrink();
  }
  return MediaQuery.applyTextStyleOverrides(
    lineHeightScaleFactorOverride: lineHeight,
    letterSpacingOverride: letter,
    wordSpacingOverride: word,
    paragraphSpacingOverride: paragraph,
    child: child ?? const SizedBox.shrink(),
  );
}

すべてのアプリのルートに組み込みます。

// Flutter 3.44.8
MaterialApp(
  builder: sanitizeTextSpacing,
  home: const HomePage(),
);

WCAG 1.4.12 が求めているのは行の高さ 1.5 と文字間隔 0.12em なので、係数の上限 4 と 100 px の上限は、実際の設定に対して十分な余裕があります。3.44.8 で、これをウィジェットテストで実行しました。624.9375 のオーバーライドは null になり、18 px の Text は 12,936 px ではなく 30 px になります。1.5 のオーバーライドは変更されずにそのまま通ります。どちらの場合も textScaler.scale(10)11.5 を返します。

ここで重要な点がいくつかあります。

修正 3: Android ホストで textZoom を 100 に固定する

ネイティブのホストも自分で出荷している場合は、WebView がシステムのフォントスケールをページに渡さないようにできます。3 つの修正の中で最も大ざっぱな方法です。textScaler が 1.0 のままになるため、Flutter web のコンテンツはユーザーのフォントサイズにまったく追従しなくなります。web アプリが独自のアプリ内テキストサイズ調整機能を持っている場合にのみ使ってください。

Kotlin のホストでは次のようにします。

// Android System WebView, API 14+
webView.settings.javaScriptEnabled = true
webView.settings.textZoom = 100

webview_flutter 4.14.1 を使う Flutter のホストでは、この設定は webview_flutter_android 4.14.1 の Android プラットフォームコントローラーにあります。

// webview_flutter 4.14.1, webview_flutter_android 4.14.1
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';

WebViewController buildController(Uri appUrl) {
  final controller = WebViewController()
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    ..loadRequest(appUrl);

  final platform = controller.platform;
  if (platform is AndroidWebViewController) {
    // Opt out of Android's system font scale for this WebView.
    platform.setTextZoom(100);
  }
  return controller;
}

これで、バグを再現できないという報告があることの説明もつきます。issue スレッドによると、flutter_inappwebview で構築されたホストは、このプラグインがデフォルトで textZoom を 100 に設定するため影響を受けません。素の android.webkit.WebViewwebview_flutter で構築されたホストは、ユーザーがフォントのスライダーをデフォルトから動かした時点で影響を受けます。

このバグではないよく似た症状

このバグに当たっているかどうかを手早く判別するには、起動時に PlatformDispatcher.instance.lineHeightScaleFactorOverride をログ出力します。web でおよそ 3 を超える値が出ている場合、それはユーザーが求めたものではなく、エンジンがプローブを読み違えたことを意味します。

関連記事

出典

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る