Expoのファイル選択が「unregistered ActivityResultLauncher」で失敗する原因 ─ Androidの文字サイズ変更で画面が作り直され、configChangesで防いだ話

当ページのリンクには広告が含まれています。
この記事の結論
アプリを開いたまま端末の文字サイズを変えると、Androidはアプリの画面(Activity)を作り直します。その後、Expoのフォルダー・ファイル選択が「unregistered ActivityResultLauncher」の英語エラーで失敗したり、押しても何も起きなくなったりしました。文字サイズ・表示サイズ・言語の変更では画面を作り直さないよう configChanges に追加し、選択画面を開けなかったときは英語のエラーではなく日本語で回避策を出すようにしました。

お疲れ様です!IT業界で働くアライグマです!

個人開発しているAndroidアプリ「終業ログ」には、端末内の記録を1つのファイルに書き出し、あとで読み込んで戻すバックアップ機能があります。このバックアップで、端末の文字サイズを変えたあとだけ、保存先の選択が英語の内部エラーで失敗し、復元のファイル選択は無反応になる不具合がありました。アプリを完全に終了して開き直すと直るため、利用者からは原因も回避策も分からない状態でした。

目次

文字サイズを変えた後、保存先選択は英語エラー、復元は無反応になった

終業ログは、1本目のアプリと同じExpo(React Native)の構成を流用して作ったアプリです。公開までの経緯は「Expoアプリを4日でGoogle Playに公開した記録」に書きました。記録は端末の中だけに保存しているので、機種変更や誤削除に備えて記録を守る手段は、利用者自身が行うバックアップと復元しかありません。

公開中の製品版で、文字サイズを変えるだけで再現した

不具合の記録には、公開中の製品版をAndroidエミュレーター(Pixel 7、Android 16)で動かして再現した手順が残っています。

  1. アプリを起動したまま、端末の文字サイズを変える(adb で文字の拡大率を1.15にしてから1.0へ戻す)
  2. アプリはホーム画面から表示し直される。プロセスは終了しておらず、同じものが動き続けている
  3. 設定の「バックアップと復元」から「保存先を選んで書き出す」を押す
  4. 続けて「復元するファイルを選ぶ」を押す

手順1で使ったコマンドは次のとおりです。

adb shell settings put system font_scale 1.15
adb shell settings put system font_scale 1.0

同じ画面の3つのボタンで、壊れ方がそれぞれ違った

手順3の「保存先を選んで書き出す」は、フォルダーを選ぶ画面が開かず、次の英語のエラーが画面にそのまま表示されました。

Call to function 'FileSystem.pickDirectoryAsync' has been rejected.
→ Caused by: java.lang.IllegalStateException: Attempting to launch an unregistered ActivityResultLauncher with contract expo.modules.filesystem.FilePickerContract … You must ensure the ActivityResultLauncher is registered before calling launch()

手順4の「復元するファイルを選ぶ」は、ファイルを選ぶ画面が開かず、エラーも案内も出ませんでした。押しても何も起きない状態です。一方、同じ画面にある「他のアプリで共有」は動きました。

アプリを強制終了して起動し直すと、フォルダー選択が開いてバックアップファイルを保存でき、ファイル選択も開きました。「アプリを開き直せば直る」のですが、それを利用者が推測するのは無理です。

IT女子 アラ美
文字サイズ変えただけでバックアップが壊れるとか、気づく人いないでしょ。

ITアライグマ
開き直せば直ってしまうので、なおさら原因が見えにくい不具合でした。

原因は、文字サイズの変更でAndroidが画面を作り直していたこと

設定が変わると、Androidは画面の入れ物を作り直す

Androidのアプリは、画面を表示する入れ物として「Activity」を持っています。Expoで作ったアプリも、中身はReact Nativeの画面ですが、外側では MainActivity という1つのActivityが動いています。

端末の文字サイズ、表示サイズ、言語などの設定が変わると、Androidは既定ではこのActivityを一度破棄して作り直します。新しい設定で画面を組み立て直すためです。ただし、アプリ側が「この種類の変更は自分で受け止めるので作り直さなくてよい」と宣言した変更は除かれます。その宣言が、マニフェストの android:configChanges です。

公開版のマニフェストを確認すると、MainActivity の configChanges には画面の向きやキーボードなどは含まれていましたが、文字サイズ(fontScale)・表示サイズ(density)・言語(locale)は入っていませんでした。そのため、文字サイズを変えるとActivityが作り直されます。再現手順で「アプリがホーム画面から表示し直された」のはこのためです。

ファイル選択の窓口が、作り直し前の画面に結び付いたまま残った

フォルダーやファイルを選ぶ画面は、アプリの外にあるAndroidのシステム画面です。アプリから別の画面を開いて結果を受け取るには、「ActivityResultLauncher」という窓口を事前にActivityへ登録しておき、そこから起動します。

使っていた expo-file-system(57.0.7)のAndroid側を読むと、この窓口をモジュールの定義で一度登録し、フォルダー選択とファイル選択の両方でそれを使っています。

RegisterActivityContracts {
  filePickerLauncher = registerForActivityResult(
    FilePickerContract(this@FileSystemModule)
  )
}

AsyncFunction("pickDirectoryAsync") Coroutine { initialUri: Uri? ->
  val result = filePickerLauncher.launch(...)
  ...
}

エラーメッセージは「登録されていない窓口を起動しようとした」という意味です。Activityが作り直されたあとも、プロセスが同じなので、作り直し前のActivityに登録した窓口から起動しようとして失敗したと考えています。ただし、これはエラー内容と実装から推定した原因です。上流の既知の不具合や修正があるかは、調べた範囲では見つけられませんでした。

なお、以前書いた「Expo Routerの言語切り替えで入力が消えた話」も「画面の作り直し」が原因でしたが、あちらはReact側で画面を組み直していた話です。今回はAndroid自身がActivityを作り直しており、React側のコードを見ても原因は出てきません。

IT女子 アラ美
作り直された新しい画面に、古い窓口だけが置き去りにされてたってこと?

ITアライグマ
はい、エラー内容からはそう読めます。アプリを開き直すと窓口も登録し直されるので直ります。

復元が無反応だったのは、失敗が「キャンセル」として返っていたから

書き出しと復元で、同じ失敗の見え方が違った

書き出しも復元も、システムの選択画面を同じ窓口から開いています。それなのに、書き出しは英語のエラーが出て、復元は何も起きませんでした。違いは、アプリが呼んでいた expo-file-system の関数の作りにありました。

書き出しで使っていたフォルダー選択は、失敗するとそのまま例外を投げます。アプリ側はその例外の message を画面に表示していたので、ネイティブ側の英語のエラーがそのまま出ました。

ファイル選択の関数は、どんな失敗でもキャンセル扱いにしていた

復元で使っていた File.pickFileAsync は、結果を「選んだファイル」か「キャンセルされた」かの形で返す関数です。中を読むと、例外が起きた場合は種類を問わず「キャンセルされた」として返していました。

// expo-file-system 57.0.7 の File.pickFileAsync(抜粋)
try {
  const file = await ExpoFileSystem.pickFileAsync(options);
  return { result: new File(file.uri), canceled: false };
} catch (e) {
  return { result: null, canceled: true };
}

アプリ側は canceled が true なら「利用者が選択をやめた」と判断し、何も表示せずに終わる作りでした。利用者が自分でやめた場合はそれで正しいのですが、選択画面を開けなかった場合も同じ扱いになります。その結果、復元のボタンは押しても何も起きない状態になっていました。

ネイティブの関数を直接呼び、キャンセルと失敗を分けた

修正では、ラッパーの File.pickFileAsync をやめ、ネイティブモジュールの pickFileAsync を直接呼ぶようにしました。ネイティブ側は、利用者がやめた場合に ERR_PICKER_CANCELLED というコードの例外を投げます。このコードのときだけキャンセルとして扱い、それ以外の例外は呼び出し元へそのまま伝えます。

function isCancel(cause: unknown): boolean {
  return (cause as { code?: unknown } | null)?.code === "ERR_PICKER_CANCELLED";
}

try {
  const picked = await getNativeFileSystem().pickFileAsync({
    mimeTypes: [MIME_TYPE, "text/plain", "application/octet-stream"],
    multipleFiles: false,
  });
  ...
} catch (cause) {
  if (isCancel(cause)) return null; // 利用者が選択をやめた
  throw cause;                      // 開けなかった等の失敗は伝える
}

IT女子 アラ美
失敗まで「キャンセルしました」で返すの、優しさの方向が間違ってるわ。

ITアライグマ
便利な関数ほど、失敗をどう返すかは一度中身を読んで確かめたいと思いました。

configChangesに文字サイズなどを足し、失敗時は日本語で回避策を出した

修正は1回の変更にまとめて入れました。中身は、画面の作り直しを防ぐこと、前の章で書いたキャンセルと失敗の区別、そして失敗したときの表示の見直しの3つです。

設定プラグインで、作り直さない変更を追加した

Expoではマニフェストを直接書かず、ビルド時に設定プラグインで書き換えます。MainActivity の configChanges に、文字サイズ・表示サイズ・言語・文字の向き(右から左など)の4つを足すプラグインを追加しました。既に入っている値は残し、重複も足さないようにしています。

const { withAndroidManifest } = require("expo/config-plugins");

const CONFIG_CHANGES_TO_ADD = ["fontScale", "density", "locale", "layoutDirection"];

function updateConfigChanges(currentValue) {
  const existing = currentValue
    ? currentValue.split("|").map((s) => s.trim()).filter(Boolean)
    : [];
  return Array.from(new Set([...existing, ...CONFIG_CHANGES_TO_ADD])).join("|");
}

module.exports = (config) =>
  withAndroidManifest(config, (config) => {
    const mainActivity = config.modResults.manifest.application?.[0]?.activity?.find(
      (a) => a.$?.["android:name"] === ".MainActivity",
    );
    if (mainActivity?.$) {
      mainActivity.$["android:configChanges"] = updateConfigChanges(
        mainActivity.$["android:configChanges"],
      );
    }
    return config;
  });

あわせて app.config.js のプラグイン一覧に登録しています。

英語のエラーをそのまま出さず、開き直しを案内する

これまでは、例外の message を何でも画面に出していました。修正後は、アプリ自身が利用者向けに書いたエラーだけをそのまま出し、選択画面を開けなかった失敗には「選択画面を開けませんでした。アプリを開き直してからもう一度お試しください。」と表示します。それ以外の想定外のエラーは、「書き出せませんでした。もう一度お試しください。」のような操作ごとの日本語の文に置き換えます。復元のファイル選択で起きた例外は、障害の記録にも残すようにしました。

マージ前のAIレビューで、共有できない端末の案内が消える問題を直した

この表示の見直しには、マージ前のAIレビューで指摘が1件付きました。「共有を使えない端末では、保存先を選んで保存してください」という案内は、アプリが書いた文なのに普通のエラーとして投げていたため、新しい判定では汎用の文に置き換わってしまう、という指摘です。同じ変更の中で、この案内もアプリ自身のエラーとして投げるように直しました。

自動テストで確かめ、修正後の実機での確認記録は残っていない

確認は自動テストで行いました。プラグインが4つの値を重複なく足すこと、選択画面の失敗時に日本語の案内が出ること、復元が無反応にならないこと、想定外のネイティブのエラーで英語を出さないことを検証しています。修正を入れた変更には、エミュレーターで文字サイズを変えたときのAndroid側の動きは未確認と書かれていました。この修正はその後の新しい版に含めて審査へ出しましたが、修正後に文字サイズを変えて試した記録は残っていません。

IT女子 アラ美
直したつもりでも、実機での確認がまだってことをちゃんと書くのね。

ITアライグマ
確かめていないことを確かめたとは書けません。実機での確認が残っています。

まとめ

端末の文字サイズを変えると、Androidはアプリの画面(Activity)を作り直します。終業ログでは、その後に expo-file-system のフォルダー・ファイル選択を開こうとすると、「unregistered ActivityResultLauncher」で失敗していました。書き出しは英語のエラー、復元は無反応と見え方が違ったのは、ファイル選択の関数が失敗も「キャンセル」として返していたためです。

修正では、文字サイズ・表示サイズ・言語などの変更で画面を作り直さないよう configChanges に追加しました。あわせて、キャンセルと失敗を区別し、開けなかったときは日本語で「アプリを開き直してください」と案内するようにしています。自動テストでは確かめましたが、修正後に実機で文字サイズを変えて試した記録はまだありません。

Expoで作ったアプリでも、外側ではAndroidのActivityが動いています。文字サイズを変えたあとにだけ壊れる不具合は、React側のコードではなく、マニフェストの configChanges から疑うと早いと思います。

IT女子 アラ美
文字サイズ変更で再現するって分かれば、テスト手順にも足せるね。

ITアライグマ
はい、端末の設定を変えてから操作する確認は、見落としやすいので覚えておきたいです。

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次