タブレットのエミュレータを起動したのは、8月31日まで残り5日という数字を見た後でした。
個人開発で運用している壁紙アプリは、どれも縦向きの一覧を前提に組んでいます。Play の期限に合わせて targetSdkVersion を 36 に上げる作業そのものは、先に済ませていました。残っていたのは、上げた後に何が変わるかの確認です。
エミュレータを横に倒したら、縦固定にしていたはずの一覧が横に広がりました。マニフェストは触っていません。
無視されるのは、この8つの値です
Android 16(API レベル 36)を対象にしたアプリでは、最小幅が 600dp 以上のディスプレイで、向き・アスペクト比・リサイズ可否の指定が無視されます。対象になるのはタブレット、大画面折りたたみ端末の内側ディスプレイ、そしてデスクトップウィンドウです。
無視される指定は次のとおりです。
| 属性・API | 無視される値 |
screenOrientation | portrait / landscape / reversePortrait / reverseLandscape / sensorPortrait / sensorLandscape / userPortrait / userLandscape |
resizeableActivity | すべて |
minAspectRatio | すべて |
maxAspectRatio | すべて |
setRequestedOrientation() | screenOrientation と同じ8値 |
この表を読むとき、私が最初に見落としていたのは「載っていない値」のほうでした。unspecified・locked・nosensor・sensor・user・fullSensor は、無視される値の一覧に入っていません。
例外もあります。sw600dp 未満の画面、つまり大半のスマートフォンと折りたたみ端末の外側ディスプレイは対象外です。android:appCategory がゲームのアプリも対象から外れます。それから、ユーザーが端末側のアスペクト比設定で従来の挙動を選んだ場合も除かれます。
つまり「スマートフォンで確認したから大丈夫」は成立しません。私がエミュレータを開くまで気づけなかったのは、まさにこの理由でした。
なお、同じ Android 16 でも予測型「戻る」の既定変更はこれとは別の仕組みで、オプトアウトの書き方も違います。そちらはAndroid 16 の予測型「戻る」の判断で扱いました。混ぜて考えると、どちらの対処も中途半端になります。
手元のプロジェクトが該当するかを、実機の前に判定する
タブレットを買いに行く前に、まず設定値を見ます。Rork や Expo のプロジェクトでは、向きの指定が入る場所が2か所あります。app.json と、prebuild 後の AndroidManifest.xml です。
この2つは食い違うことがあります。prebuild の後にマニフェストを手で直していると、app.json を読んでも実体が分かりません。逆に、マニフェストを消して再生成する運用なら app.json が正です。
両方を見て、実際に効いている値を出す小さなスクリプトを書きました。
// check-orientation.mjs — プロジェクトルートで node check-orientation.mjs
import { readFileSync, existsSync } from 'node:fs';
// Android 16 (API 36) が sw600dp 以上で無視する screenOrientation の値
const IGNORED = new Set([
'portrait', 'landscape',
'reversePortrait', 'reverseLandscape',
'sensorPortrait', 'sensorLandscape',
'userPortrait', 'userLandscape',
]);
function fromAppJson(path) {
if (!existsSync(path)) return null;
const json = JSON.parse(readFileSync(path, 'utf8'));
const cfg = json.expo ?? json;
// app.json の orientation は 'default' | 'portrait' | 'landscape' の3値のみ
// 'default' は android:screenOrientation="unspecified" に落ちる
const o = cfg.orientation;
if (!o) return null;
return o === 'default' ? 'unspecified' : o;
}
function fromManifest(path) {
if (!existsSync(path)) return null;
const xml = readFileSync(path, 'utf8');
// prebuild 後に手で書き換えている場合はこちらが実体
const m = xml.match(/android:screenOrientation="([^"]+)"/);
return m ? m[1] : null;
}
const manifestPath = 'android/app/src/main/AndroidManifest.xml';
const manifestValue = fromManifest(manifestPath);
const appJsonValue = fromAppJson('app.json') ?? fromAppJson('app.config.json');
const effective = manifestValue ?? appJsonValue ?? 'unspecified(未指定)';
const source = manifestValue ? manifestPath : (appJsonValue ? 'app.json' : 'どちらにも指定なし');
console.log(`screenOrientation = ${effective} (source: ${source})`);
if (manifestValue && appJsonValue && manifestValue !== appJsonValue) {
console.log(`⚠️ app.json(${appJsonValue}) と Manifest(${manifestValue}) が食い違っています`);
}
if (IGNORED.has(effective)) {
console.log('❌ sw600dp 以上の画面ではこの指定は無視されます');
process.exitCode = 1;
} else {
console.log('✅ 無視対象の値ではありません');
}
app.json に "orientation": "portrait" だけがある状態で走らせると、次のように出ます。
screenOrientation = portrait (source: app.json)
❌ sw600dp 以上の画面ではこの指定は無視されます
prebuild 済みのプロジェクトで、マニフェスト側が unspecified に書き換わっている場合はこうなります。
screenOrientation = unspecified (source: android/app/src/main/AndroidManifest.xml)
⚠️ app.json(portrait) と Manifest(unspecified) が食い違っています
✅ 無視対象の値ではありません
終了コードを返すようにしてあるので、複数のアプリを抱えている場合はディレクトリを回して一括で確認できます。私は手元のリポジトリを一巡させて、対象になるものを先に洗い出しました。実機を触るのは、その後です。
app.json の側に、逃げ道が用意されていません
対象だと分かったところで、まず app.json を見ました。orientation の値を、無視されない値へ変えられないかと思ったからです。
変えられません。Expo の設定スキーマでは、orientation が取れる値は default / portrait / landscape の3つだけです。手元で @expo/config-types の型定義を確認したところ、次のように定義されていました。
orientation?: 'default' | 'portrait' | 'landscape';
そして、この値がマニフェストへどう落ちるかも決まっています。@expo/config-plugins の該当箇所は次の形でした。
function setAndroidOrientation(config, androidManifest) {
const orientation = getOrientation(config);
if (!orientation) {
return androidManifest;
}
const mainActivity = getMainActivityOrThrow(androidManifest);
mainActivity.$['android:screenOrientation'] =
orientation !== 'default' ? orientation : 'unspecified';
return androidManifest;
}
portrait は加工されず、そのまま android:screenOrientation="portrait" になります。無視される8値の先頭にある、あの値です。
つまり app.json で選べる3値のうち、2つは無視対象で、残る1つは向きの指定を捨てるという意味になります。中間がありません。無視リストに載っていない locked や nosensor は、app.json からは指定できない値です。
ここが、この変更で個人開発者が最初に詰まる場所だと感じています。設定ファイルを眺めている限り、打つ手が見えません。
オプトアウトのプロパティは、向きのロックまでは戻しません
公式には、API 36 の挙動から一時的に抜けるための仕組みが用意されています。マニフェストに android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY というプロパティを宣言する方法です。
ここで、私が読み違えていた点があります。
このプロパティを書いても、大画面での画面の向きはロックされません。回転も止まりません。Android のドキュメントには、API 36 以上を対象にしている場合、このプロパティは大画面ディスプレイの向きをロックせず回転も防がない、と明記されています。
戻るのはリサイズ可否まわりの制限であって、「8月31日までにプロパティを1行足しておけば従来どおり縦のまま」という理解は成り立ちません。
私はこれを、config plugin を書き終えてエミュレータで確認するまで、逆に理解していました。プロパティを入れたのに縦に戻らないので実装を疑い、ドキュメントに戻って気づいた、という順序です。実装のミスではありませんでした。
さらに、この仕組みには期限もあります。API 37 を対象にするアプリではオプトアウト自体が廃止され、sw600dp 以上では常に無視される、と予告されています。Play の必須化が API 36 は 2026年8月、API 37 は 2027年8月ですから、猶予は実質1年です。
| 対象 API レベル | 影響を受ける端末 | オプトアウト |
| 36(Android 16) | 最小幅 600dp 以上の大画面端末 | 可(ただし向きのロックは戻らない) |
| 37(Android 17) | 同上 | 不可 |
この表を作ってから、私は方針を変えました。1年で消える仕組みに合わせて実装を曲げるより、横向きで壊れる画面を先に直したほうが、結果的に手数が少ないと考えたからです。
config plugin で property を入れる
それでもリサイズ制限の側だけは戻したい、という判断はあり得ます。レイアウトの修正が期限に間に合わない場合です。その場合は config plugin を書きます。
Expo には <property> 要素を app.json から書く口がないため、マニフェストを直接編集する plugin が必要になります。
// plugins/with-restricted-resizability.js
const { withAndroidManifest, AndroidConfig } = require('@expo/config-plugins');
const PROPERTY_NAME =
'android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY';
module.exports = function withRestrictedResizability(config) {
return withAndroidManifest(config, (cfg) => {
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(
cfg.modResults
);
// 既に入っている場合は重複させない(prebuild を繰り返すと二重に足される)
app.property = app.property ?? [];
const exists = app.property.some(
(p) => p.$['android:name'] === PROPERTY_NAME
);
if (!exists) {
app.property.push({
$: {
'android:name': PROPERTY_NAME,
'android:value': 'true',
},
});
}
return cfg;
});
};
app.json の plugins に追加します。
{
"expo": {
"plugins": ["./plugins/with-restricted-resizability.js"]
}
}
同じ内容を @expo/config-plugins 57.0.9 の XML 変換に通したところ、<application> の直下に次の要素が出力されました。
<application android:name=".MainApplication" android:label="@string/app_name">
<activity android:name=".MainActivity" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<property android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY" android:value="true"/>
</application>
特定のアクティビティだけに効かせたい場合は、getMainApplicationOrThrow を getMainActivityOrThrow に変えて同じ形で足します。アプリ全体に効かせるか、画面単位で切るかは、横向きで壊れる画面がどれだけあるかで決めることになります。
TypeScript で書いている方には、先に伝えておきたいことがあります。@expo/config-plugins の型定義では、ManifestApplication に property というフィールドがありません。meta-data と activity はあるのに、property だけが抜けています。実行時は xml2js がそのまま要素にしてくれるので動きますが、型の上では自分で拡張するか、その一行だけキャストを入れる必要があります。
meta-data で書くと、何も言わずに通ります
ここが、この作業でいちばん危ないところだと感じました。
@expo/config-plugins には addMetaDataItemToMainApplication という便利な関数があります。マニフェストに項目を足す関数を探すと、まずこれが見つかります。プロパティ名を渡せば、それらしく通ります。
通りますが、出力は <property> ではありません。
<property android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY" android:value="true"/>
<meta-data android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY" android:value="true"/>
上が正しい書き方、下が addMetaDataItemToMainApplication を使った場合です。手元で両方を1つのマニフェストに出力して並べたものが、この2行です。
Android は <property> と <meta-data> を別のものとして扱います。プロパティとして読ませたい値を <meta-data> に書いても、ビルドは成功し、警告も出ず、実機でも例外は起きません。ただ効かないだけです。
設定の書き間違いが無言で通る種類のものは、後から原因を探すのに時間がかかります。私はこの手の「エラーにならない失敗」に何度か時間を溶かしてきたので、今回はプロパティを足した直後に、生成されたマニフェストを目で見て確認する手順を挟みました。
# prebuild 後に、実際に property として出ているかを確認する
npx expo prebuild --platform android --no-install
grep -n "PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY" \
android/app/src/main/AndroidManifest.xml
出力行の要素名が <property で始まっていれば正解です。<meta-data で始まっていたら、書き方を間違えています。
期限と、私がどこで線を引いたか
整理すると、今週の判断材料は3つあります。
- 対象は大画面だけです。 スマートフォンしか想定していないアプリなら、8月31日の期限そのものとは切り離して考えられます。ただし「想定していない」と「大画面で表示されない」は別です。タブレットにも配信しているなら対象です。
- オプトアウトは向きのロックを戻しません。 プロパティを足しても、タブレットでは横向きになります。横で崩れる画面があるなら、結局そこを直す必要があります。
- 猶予は1年です。 API 37 でオプトアウトが消えたあとは、選択肢そのものがなくなります。
私は、横向きで実際に壊れる画面だけを先に直し、プロパティは入れないという線にしました。壁紙の一覧はグリッドなので、列数を画面幅から決めるように変えれば済みます。詳細画面のほうは、画像に最大幅を与えるだけで収まりました。
修正量が読めない状態で 8月31日を迎えそうなら、判断は変わります。この場合はプロパティを入れて期限を通し、次の更新でレイアウトを直すほうが安全です。ただしそのときも、向きは横になるという前提で提出前の確認をしてください。
targetSdkVersion を 36 に上げる作業そのものでつまずいている場合は、targetSdkVersion 36 を通すまでに直した3か所のほうが先に必要になるはずです。期限の意味そのものについては、更新を出す人と出さない人で意味が違う話にまとめてあります。
まとめ
まず、この記事の検査スクリプトを手元のプロジェクトルートで一度走らせてみてください。portrait と出るなら対象です。そこから先の判断は、横向きで壊れる画面が何枚あるかを数えてから決めるのが確実だと思います。
私自身、エミュレータを横に倒すまでこの変更を自分ごとと思っていませんでした。同じ順序で気づく方が減れば嬉しく思います。お読みいただきありがとうございました。