targetSdkVersion を 36 に上げたあと、プレビュー画面で端から内側へスワイプしたときの見え方が変わっていました。画面が少し縮んで、後ろが覗く。Android 側の予測型「戻る」のアニメーションです。
そこまでは知っていた変更でした。手が止まったのは、その先です。プレビューから一覧へ戻るつもりの操作が、アプリの終了として扱われる場面がありました。
React Native 側の告知には「BackHandler は引き続き動作します」と書かれています。実際、BackHandler は動いていました。それでも戻る操作の結果は変わっていた。この二つが同時に成り立つ理由を理解するまでに、少し時間がかかりました。
「BackHandler は動く」と「戻る操作が変わらない」は別の話です
Android 16 を対象にするアプリでは、予測型「戻る」が既定で有効になります。このとき OS 側で起きているのは、次の二つです。
| 従来(targetSdk 35 以下) | targetSdk 36 |
onBackPressed() が呼ばれる | 呼ばれない |
KeyEvent.KEYCODE_BACK が配送される | 配送されない |
React Native 0.81 では、この変化を吸収して JS 側の BackHandler を動かし続けるための修正が入っています。コミュニティのアナウンスには、onBackPressed はもう呼ばれないが BackHandler は動作を続けるはずで、独自のネイティブ側の戻る処理を持っている場合は OnBackPressedDispatcher へ手で移行する必要がある、と書かれています。
ここで私が読み違えていたのが、「BackHandler が動く」の射程でした。動くのは、自分で hardwareBackPress に登録したハンドラです。ハンドラを登録していない画面 — つまりナビゲーションライブラリに戻る操作を任せている画面 — の挙動までが保証されているわけではありません。
そして React Navigation は、この記事を書いている時点で予測型「戻る」に追随しきれていません。公式ドキュメントは、システムの戻るジェスチャーを期待どおり動かすために android:enableOnBackInvokedCallback を false にする案内を出しています。Expo 側でも SDK 54 でスタック内の戻る操作が効かなくなる報告が上がっており、react-native-screens の新しいネイティブスタックで対応が進んでいる段階です。
つまり、壊れうるのは自分が書いた BackHandler ではなく、書かなかった箇所のほうでした。これが今回いちばん直感に反した点です。自分のコードを読み返しても、壊れる理由は見つかりません。
opt-out フラグが止めるものと、止めないもの
android:enableOnBackInvokedCallback="false" は、一時的な回避として公式に案内されているフラグです。ただしこのフラグの効き方には、把握しておかないと判断を誤る非対称があります。
| 対象 | フラグを false にしたとき |
| 予測型「戻る」のシステムアニメーション | 無効になる |
OnBackInvokedCallback | 無視される |
OnBackPressedCallback | 値によらず呼ばれ続ける |
OnBackPressedCallback はフラグの値に関係なく呼ばれます。ですので、AndroidX の作法で戻るを扱っている部分は、opt-out を入れても入れなくても動きます。フラグで戻せるのは、あくまで OS 側のアニメーションと新しいコールバック経路のほうです。
もう一点。このフラグは <application> にも <activity> にも書けます。両方に書いた場合は個別の指定が優先されるため、<application> に false を入れたのに <activity> 側へ過去の検証で true が残っていると、その画面だけ挙動が揃いません。私はこの取りこぼしを機械で潰すことにしました(後述のスクリプトで <activity> 側も見ているのはこのためです)。
期限までに必要な作業と、切り離してよい作業
ここが今回の判断の核です。8月31日に Google Play が要求するのは、提出するアプリバンドルの対象 API レベルが 36 であることです。予測型「戻る」に完全対応していることではありません。
この二つを一緒に扱うと、期限までの日数を全部使ってもナビゲーションの再設計が終わらない、という状態になります。実際、私は最初それを一続きの作業として見積もっていました。
| 作業 | 8月31日まで | 後回しにできるか |
targetSdkVersion を 36 に引き上げる | 必須 | 不可 |
| edge-to-edge のインセット対応 | 必須 | 不可(表示が実際に崩れるため) |
| opt-out フラグを入れて従来の戻る挙動に寄せる | 推奨 | —(数分で入る) |
| 予測型「戻る」に沿ったナビゲーションの本対応 | 不要 | 可 |
opt-out は恒久策ではありません。Google 自身が一時的な措置として案内しており、将来のバージョンで外せなくなる可能性を織り込んでおく必要があります。それでも、期限のある提出と、期限のない体験改善を、同じ週に詰め込まない判断は取れます。
私はこの切り分けを、リスクの向きで決めました。期限を落とすと更新自体が出せなくなり、取り返しがつきません。戻るのアニメーションが従来のままであることによる損失は、それに比べれば戻せます。片方が不可逆で、もう片方が可逆であるとき、私は不可逆なほうを先に潰します。
引き上げそのものの手順は別記事にまとめています(Rork のプロジェクトで targetSdkVersion 36 を通すまでに直した3か所)。この記事は、その後ろ半分にあたります。
実機を触る前に、対象を機械で絞る
複数のアプリを抱えていると、全部を実機で1画面ずつ確認するのは現実的ではありません。私の場合、戻る操作で閉じる UI を持っているのはプレビュー画面とモーダルだけで、残りの画面はナビゲーションに任せています。ならば「BackHandler を使っていて、かつ manifest に opt-out がない」アプリだけを先に見ればよいはずです。
その仕分けを Node のスクリプトにしました。引数にアプリのルートを並べて実行します。
// audit-back.mjs — 複数アプリの戻る処理と manifest の指定を突き合わせる
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const roots = process.argv.slice(2);
function walk(dir, out = []) {
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir)) {
// 生成物と依存は見ない。android/ios は manifest 側で別途読む
if (name === 'node_modules' || name === '.git' || name === 'android' || name === 'ios') continue;
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.(ts|tsx|js|jsx)$/.test(name)) out.push(p);
}
return out;
}
// application 要素と activity 要素で意味が違うため、要素ごとに分けて返す
function readManifestFlag(manifestPath) {
if (!existsSync(manifestPath)) return { exists: false };
const xml = readFileSync(manifestPath, 'utf8');
const pick = (tag) => {
// 開始タグ1つ分だけを対象にする(属性が改行で折り返されていても拾える)
const m = xml.match(new RegExp(`<${tag}\\b[^>]*>`, 's'));
if (!m) return null;
const attr = m[0].match(/android:enableOnBackInvokedCallback\s*=\s*"(true|false)"/);
return attr ? attr[1] : null;
};
return { exists: true, application: pick('application'), activity: pick('activity') };
}
for (const root of roots) {
const files = walk(join(root, 'src'));
const backHandlerFiles = files.filter((f) => /BackHandler/.test(readFileSync(f, 'utf8')));
const f = readManifestFlag(join(root, 'android/app/src/main/AndroidManifest.xml'));
let state;
if (!f.exists) state = 'manifestなし(prebuild生成前)';
else if (f.application === 'false' || f.activity === 'false') state = 'opt-out あり';
else if (f.application === 'true' || f.activity === 'true') state = '明示的に opt-in';
else state = '未指定(API36で予測型「戻る」が既定有効)';
const risk = backHandlerFiles.length > 0 && state.startsWith('未指定') ? 'REVIEW' : 'ok';
console.log(
`${risk.padEnd(6)} ${root.split('/').pop().padEnd(14)} ` +
`BackHandler使用=${String(backHandlerFiles.length).padStart(2)} manifest=${state}`
);
}
手元でサンプルの3プロジェクトを作って通した出力が、次のものです。
REVIEW wallpaper-a BackHandler使用= 1 manifest=未指定(API36で予測型「戻る」が既定有効)
ok wallpaper-b BackHandler使用= 1 manifest=opt-out あり
ok wallpaper-c BackHandler使用= 0 manifest=明示的に opt-in
この3本のうち、実機で見る必要があるのは1本だけという結果になりました。REVIEW が付いた行だけを見る順序にすると、確認の対象がはっきり減ります。個人開発では確認に使える時間そのものが限られるので、私はこの絞り込みを先に済ませるようにしています。ok の行は「見なくてよい」ではなく「今回の変更で新しく壊れる筋がない」という意味です。
manifest が存在しない(android/ を生成していない)プロジェクトが混ざると、ここは manifestなし になります。その場合の指定先は manifest ではなく、次の config plugin のほうです。
opt-out は manifest に手書きしない
ここで踏みやすい落とし穴があります。AndroidManifest.xml を直接編集して opt-out を入れると、expo prebuild を走らせたときに消えます。SDK 57 では prebuild の既定が android と ios のディレクトリを破棄して再生成する側に変わりました。手書きの変更を守りたい場合は --no-clean を渡す必要があります。
期限が迫っている時期に、この既定変更と手書きの修正がかち合うのは相性が悪い組み合わせです。私は「直したはずのものが、次のビルドで静かに戻っている」という事故を避けたかったので、manifest には書かず config plugin に置くことにしました。プロジェクトのネイティブ差分をどこまで棚卸ししておくべきかは、別記事に整理しています(Expo SDK 57 に上げる前に、prebuild で消えるネイティブ変更を洗い出す)。
プラグインは次の形になります。
// plugins/with-back-invoked-optout.js
const { withAndroidManifest } = require('@expo/config-plugins');
// AndroidManifest の JSON 表現(xml2js 形式)を書き換える部分だけを関数に切り出す
function setEnableOnBackInvokedCallback(androidManifest, value) {
const app = androidManifest.manifest.application?.[0];
if (!app) throw new Error('application 要素が見つかりません');
app.$['android:enableOnBackInvokedCallback'] = String(value);
// activity 側の指定は application より優先されるため、残っていると揃わない
for (const activity of app.activity ?? []) {
delete activity.$['android:enableOnBackInvokedCallback'];
}
return androidManifest;
}
module.exports = function withBackInvokedOptOut(config, { enabled = false } = {}) {
return withAndroidManifest(config, (config) => {
config.modResults = setEnableOnBackInvokedCallback(config.modResults, enabled);
return config;
});
};
module.exports.setEnableOnBackInvokedCallback = setEnableOnBackInvokedCallback;
app.json からはこう読み込みます。
{
"expo": {
"plugins": [["./plugins/with-back-invoked-optout", { "enabled": false }]]
}
}
書き換え部分を setEnableOnBackInvokedCallback として外に出しているのは、テストのためです。@expo/config-plugins を通さずにこの関数だけを呼べば、manifest の JSON 表現を手で組んで検証できます。
const manifest = {
manifest: {
application: [
{
$: { 'android:name': '.MainApplication' },
activity: [
{ $: { 'android:name': '.MainActivity', 'android:enableOnBackInvokedCallback': 'true' } },
],
},
],
},
};
setEnableOnBackInvokedCallback(manifest, false);
// application: {"android:name":".MainApplication","android:enableOnBackInvokedCallback":"false"}
// activity : {"android:name":".MainActivity"}
// prebuild は複数回走るので、二重適用しても壊れないことを見ておく
setEnableOnBackInvokedCallback(manifest, false);
// application: {"android:name":".MainApplication","android:enableOnBackInvokedCallback":"false"}
activity 側の指定を消す1行が入っているのは、前節で触れた優先順位のためです。ここを入れずにいると、<application> に false を書いたのに一部の画面だけ挙動が違う、という追いにくい状態になります。過去の検証で true を入れて放置した activity がある場合、症状は「特定の画面でだけ戻るがおかしい」という形で出ます。
将来 opt-out をやめるときは、enabled を true にするだけで済みます。プラグインとして残しておくと、「いつ・なぜ入れたか」がリポジトリの履歴に残る点も、しばらく後の自分にとって助かります。
実機で見るのは、戻る操作を横取りしている箇所だけです
REVIEW が付いたアプリで私が確認したのは、次の3か所でした。どれも「戻るを自分で握っている」箇所です。
- プレビュー画面からの復帰 — 壁紙を全画面表示している状態から一覧へ戻る動線です。ここが終了になると、体感としていちばん大きく壊れます
- モーダルとボトムシート — 開いた状態での戻るが、シートを閉じるのか画面ごと戻るのかを見ます。表示状態を握っているコンポーネントが複数あると、ここで取り合いが起きます
- 終了確認のダイアログ — ルート画面での戻るに
true を返して確認を挟んでいる場合、この分岐が意図どおり通るかを見ます
戻るまわりの実装そのものでつまずいている場合は、予測型「戻る」以前の話である可能性もあります(RorkのAndroidアプリで戻るボタンが効かない — BackHandlerの落とし穴と直し方)。まず従来の挙動で正しく動いていることを確かめてから、対象 API レベルを上げた状態を見る順序が、切り分けとしては楽です。
この3か所は、いずれも数分で確認できます。私自身、確認そのものより「どこを見ればいいか分からない」時間のほうが長くなりがちでした。リストにしておくと、そこが消えます。
エミュレータではなく実機を1台通すようにしているのは、ジェスチャーの扱いがメーカーの実装に左右されるためです。3ボタンナビゲーションとジェスチャーナビゲーションで見え方が変わる点も、実機でないと気づきにくいところでした。
いま手を動かすなら
app.json でも build.gradle でもなく、AndroidManifest.xml を開いてください。
enableOnBackInvokedCallback の指定が <application> と <activity> のどちらにもなければ、対象 API レベルを 36 にした時点で、あなたのアプリの戻る操作は新しい経路に切り替わっています。ビルドは通り、BackHandler も動き、Play Console も何も言いません。変わっているのは、ユーザーが指を滑らせたときの結果だけです。
期限までに間に合わせるべきなのは引き上げのほうで、戻る体験の作り直しはそのあとで構いません。ただ、切り替わっていることを知らないまま提出するのと、知ったうえで後回しにするのとでは、次に届くレビューの読み方が変わってきます。