[Flutter] split('') の罠 — 絵文字や一部の漢字で発生する Android クラッシュ

2026-05-27 hit count image

一部の漢字や絵文字を含む単語を1文字ずつ分割するときに発生する "string is not well-formed UTF-16" Android クラッシュの原因と修正方法を整理します。

flutter

概要

日本語の単語学習アプリで、ある日次のような Crashlytics レポートが上がってきました。

Fatal Exception: java.lang.RuntimeException
... string is not well-formed UTF-16 ...

クラッシュした単語は 𩸽 (ほっけ — 日本の家庭でよく焼いて食べる魚) でした。この1文字を、画面に1文字ずつ分けて表示するウィジェットに入れた瞬間に Android 端末でアプリが落ちます。

原因は Dart の String.split('')UTF-16 コードユニット単位 で文字列を切るためです。𩸽 のような絵文字や一部の漢字(Unicode の 補助多言語面 領域、U+10000 以上)は UTF-16 では surrogate pair(2つのコードユニット)で表現されるので、split('') はこのペアを2つに割って lone surrogate を作ってしまいます。lone surrogate は有効な UTF-16 ではないので、platform channel を通して Android 側に渡された瞬間に “string is not well-formed UTF-16” として拒否されます。

このブログでは次を整理します。

  • String.split('') の動作はなぜ「1文字ずつ」ではないのか
  • Unicode 補助多言語面と UTF-16 surrogate pair の簡単な背景
  • runes ベースの安全な分割ヘルパーと適用パターン
  • 罠を捕まえておくテストの書き方

なぜ split('') が問題なのか

Dart の String は実は UTF-16 コードユニットが順番に並んだもの です。文字(character/grapheme)が並んだものではありません。この事実は次の2行ですぐに確認できます。

print('𩸽'.length);            // 2  (コードユニット2個)
print('𩸽'.split('').length);   // 2  (それぞれ lone surrogate 1個ずつ)

人の直感では「𩸽 は1文字」ですが、Dart からすると [0xD867, 0xDE3D] の2つのコードユニットです。split('') はこの2つのユニットをそれぞれ長さ1の文字列に分割します。分割結果の2つの破片はそれぞれ次の通りです。

  • 1番目の破片: high surrogate(U+D867)だけが単独で存在
  • 2番目の破片: low surrogate(U+DE3D)だけが単独で存在

UTF-16 では high/low surrogate は 必ずペアでのみ存在する ことで有効な文字列になります。したがってこの分割結果をそのまま Flutter の Text のようなウィジェットに入れると、platform channel のエンコード段階で invalid UTF-16 と判定され、次のようなケースでクラッシュが発生します。

  • Android の TextView などが platform channel を通して受け取った文字列を検証するとき
  • StandardMessageCodec が String をシリアライズするとき
  • 一部のネイティブテキスト測定 / シェーピング API が well-formed UTF-16 を要求するとき

iOS が同じ入力でクラッシュしないとしても、それは OS / テキストエンジンの寛容度の違いに過ぎず、lone surrogate を作るという行為そのものが間違い です。目に見えないところでテキスト測定値がずれたり、フォントフォールバックが失敗するなど、silent なリグレッションが一緒に起きる可能性があります。

Unicode 補助多言語面と surrogate pair

背景を簡単に整理します。

  • Unicode はコードポイントを17個の「plane」に分けて管理します。
  • BMP(Basic Multilingual Plane, U+0000 ~ U+FFFF) が plane 0 で、最もよく使う文字が入ります。ASCII、ハングル、ほとんどの漢字、カタカナ / ひらがななどがすべて BMP です。
  • Supplementary Plane(補助多言語面、U+10000 ~ U+10FFFF) は plane 1~16 で、BMP に入りきらなかった追加の文字が入ります。

UTF-16 エンコーディングは BMP 文字はコードユニット1個で、補助多言語面の文字はコードユニット2個(surrogate pair)で表現します。

'A'    (U+0041)   → [0x0041]
'漢'   (U+6F22)   → [0x6F22]
'😀'   (U+1F600)  → [0xD83D, 0xDE00]  ← surrogate pair
'𩸽'   (U+29E3D)  → [0xD867, 0xDE3D]  ← surrogate pair

日本語学習アプリがよく出会う補助多言語面の文字の例です。

  • 𩸽 (U+29E3D、ほっけ — 魚)
  • 𠮷 (U+20BB7、よし — 苗字「吉」の異体字、飲食店の看板でよく見る)
  • 𠀋 (U+2000B、じょう — 人名漢字)
  • そしてほとんどすべての絵文字の半分以上(😀、🎉、🍣 など)

CJK Unified Ideographs Extension B (U+20000~U+2A6DF) 以降のすべての漢字、そして ほぼすべての絵文字 が補助多言語面に属します。漢字学習でも絵文字処理でも、補助多言語面を避けることはできません。

修正 — runes ベースの分割ヘルパー

Dart はコードポイント単位の反復のために String.runes を提供します。runes は surrogate pair を自動的にまとめて1つのコードポイントとして扱います。

print('𩸽'.runes.length); // 1

これを使った分割ヘルパーはシンプルです。

/// 文字列を Unicode コードポイント単位で分割する。
///
/// `String.split('')` は UTF-16 コードユニット単位で分割するため、補助多言語面
/// (例: 𩸽 U+29E3D、絵文字)の文字の surrogate pair を壊して
/// "string is not well-formed UTF-16" クラッシュを起こす。
/// この関数は `runes` を使ってコードポイント単位で分割し、surrogate pair を
/// 1文字として保存する。
List<String> splitByCodePoint(String text) {
  return text.runes.map((r) => String.fromCharCode(r)).toList();
}

核心はたった1行(text.runes.map((r) => String.fromCharCode(r)).toList())です。String.fromCharCode(int) は引数が補助多言語面のコードポイントの場合、自動的に正しい surrogate pair に変換された長さ2の String を作ってくれます。したがって結果リストの各要素は、人が認識する「1文字」と一致します。

splitByCodePoint('𩸽');       // ['𩸽']
splitByCodePoint('𩸽の魚');   // ['𩸽', 'の', '魚']
splitByCodePoint('a😀b');     // ['a', '😀', 'b']
splitByCodePoint('');         // []

参考: runes も万能ではありません。結合文字(例: e + ´ = é)や ZWJ 絵文字シーケンス(👨‍👩‍👧)は複数のコードポイントが集まって1つの grapheme を成します。このような grapheme 単位で正確に切るには、characters パッケージの String.characters を使う必要があります。ただし、本 fix の目的は「surrogate pair が壊れないようにする」ことなので runes で十分です。どの精度が必要かはドメインによります。

適用 — 呼び出し箇所を一括置換

問題の本質は .split('') が使われているすべての場所に同じ罠が存在することです。単一の呼び出しだけ直しても、他のウィジェットで同じクラッシュが再発します。.split('') の呼び出しを grep ですべて見つけて一括置換します。

# プロジェクト全体で .split('') 呼び出しを検索
grep -rn "\.split('')" lib/

私が開発しているプロジェクトでは、次のような共通パターンで修正しました。

// Before
word.split('').map((char) { ... });

// After
splitByCodePoint(word).map((char) { ... });

STT 比較ロジックでは、CJK テキストを文字単位でトークン化する経路で同じパターンが使われていました。

// Before — split('') でトークン化
return text
    .replaceAll(' ', '')
    .split('')
    .where((c) => c.isNotEmpty)
    .toList();

// After — コードポイント単位の分割で補助多言語面の漢字(U+29E3D 𩸽 など)を保存
return splitByCodePoint(text.replaceAll(' ', ''))
    .where((c) => c.isNotEmpty)
    .toList();

この変更自体はリスクが低いです。BMP 文字だけが入っている場合の動作は完全に同じで、補助多言語面の文字が入ってきたときだけ動作が「正しい方向」に変わります。

テスト — split('') と明示的に対比する

この fix で最も学習価値が高いのはテストです。ヘルパーの正しい動作を検証するだけでなく、split('')splitByCodePoint が異なる動作をするという事実そのものをテストとして残します。こうしておけば、将来誰かがヘルパー呼び出しを split('') に戻したらテストが明確に失敗します。

group('`splitByCodePoint`', () {
  test('BMP 文字をコードポイント単位で分割する', () {
    expect(splitByCodePoint('漢字'), ['漢', '字']);
    expect(splitByCodePoint('ご飯'), ['ご', '飯']);
    expect(splitByCodePoint('abc'), ['a', 'b', 'c']);
  });

  test('空文字列は空リストを返す', () {
    expect(splitByCodePoint(''), <String>[]);
  });

  test('補助多言語面(SMP)の漢字を1文字として保存する', () {
    // 𩸽 (U+29E3D, ほっけ): UTF-16 では surrogate pair で表現される
    expect(splitByCodePoint('𩸽'), ['𩸽']);
    expect(splitByCodePoint('𩸽の魚'), ['𩸽', 'の', '魚']);
  });

  test('絵文字を1文字として保存する', () {
    // 😀 (U+1F600): UTF-16 では surrogate pair で表現される
    expect(splitByCodePoint('😀'), ['😀']);
    expect(splitByCodePoint('a😀b'), ['a', '😀', 'b']);
  });

  test('`split` と違って surrogate pair を壊さない', () {
    // String.split('') は UTF-16 コードユニット単位で分割するので surrogate pair が壊れる
    expect('𩸽'.split('').length, 2);
    // splitByCodePoint はコードポイント単位で分割するので1文字のまま維持される
    expect(splitByCodePoint('𩸽').length, 1);
  });
});

最後のテストの2行が核心です。

expect('𩸽'.split('').length, 2);          // 罠の存在を明示
expect(splitByCodePoint('𩸽').length, 1);  // ヘルパーの約束を明示

テストが バグの本質を文書化 します。コードを初めて読む人が2行を見て即座に「なぜヘルパーが必要なのか」を理解できます。単純な動作検証以上の価値を持つパターンです。

ウィジェット側にも、補助多言語面の文字を含む単語をレンダリングするテストを追加します。ヘルパーを使用するウィジェットごとに、同じ形で1行のテストを残しておけば良いです。

testWidgets('補助多言語面の漢字(𩸽)を含む単語をクラッシュなしでレンダリングする', (tester) async {
  await tester.pumpWidget(MaterialApp(home: WordText(word: '𩸽')));
  expect(tester.takeException(), isNull);
});

tester.takeException() が null であることを断言すれば、「レンダリング中に例外が出なかった」ことが保証されます。

まとめ

.split('') の罠と解決を1行ずつ要約すると次の通りです。

  • Dart の String は UTF-16 コードユニットが順番に並んだもの である。.length.split('')[] インデックス参照のすべてがコードユニット単位で動作する。
  • 補助多言語面の文字(U+10000 以上)は UTF-16 で surrogate pair(2 コードユニット)として表現されるsplit('') はこのペアを2つに割って lone surrogate を作る。
  • lone surrogate は有効な UTF-16 ではない。Android の platform channel / TextView が well-formed UTF-16 を要求する地点で “string is not well-formed UTF-16” クラッシュにつながる。
  • 修正は runes ベースの splitByCodePoint ヘルパー1つで十分.split('') の呼び出しを grep ですべて見つけて一括置換する。
  • テストで罠の存在を明示する'𩸽'.split('').length == 2splitByCodePoint('𩸽').length == 1 を同じテストに置けば、将来のリグレッションを防げて、テスト自体がドキュメントになる。
  • 日本語 / 漢字学習アプリ + 絵文字を扱うアプリはすべて潜在的な影響範囲 にある。𩸽𠮷😀 のいずれか一つでもユーザー入力 / DB / STT 結果として入ってくる可能性があれば、同じ罠が潜伏している。

.split('') は「1文字ずつ処理」という名札を付けたまま、実はコードユニット処理をする関数です。Dart/Flutter プロジェクトで一度 grep -rn "\.split('')" lib/ を回して、潜伏した補助多言語面の時限爆弾がないか点検しておくことをお勧めします。

関連資料

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。



SHARE
Twitter Facebook RSS