壁紙アプリの宣伝枠に「TikTok を開く」「X でシェアする」を追加した翌日、レビュアーから「iPhone で押しても何も起きない」という連絡が立て続けに 3 件届いたことがあります。シミュレーターでも開発用クライアントでも動いていたので、ストア配布ビルドだけで起きる症状の切り分けに半日かかりました。
原因は拍子抜けするほど単純で、Info.plist に LSApplicationQueriesSchemes を一行も書いていなかっただけでした。コードを疑い続けた半日は、いま思えば設定ファイルを開くまでの遠回りだったのだと思います。
「開けるか」を聞く前に、聞いてよい相手を宣言しておきます。 今回の線引きは、この一文に尽きます。
まず症状の特徴を確かめる
似たような不具合に見えても、LSApplicationQueriesSchemes 起因かどうかは挙動でほぼ判別できます。次の 4 つが揃っていれば、ほぼ確定です。
| 観察されること | 意味 |
|---|---|
| シミュレーターと開発ビルドでは動き、TestFlight や本番ビルドだけ動かない | ビルドごとに `Info.plist` の中身が違う可能性 |
| `canOpenURL("tiktok://...")` が `false` を返す、または reject される | スキームが未宣言 |
| Xcode のコンソールに `This app is not allowed to query for scheme tiktok` と出る | 未宣言の決定的な証拠 |
| Android の同じコードは動いている | iOS 固有の制限(ただし Android 11 以降にも別の制限があります。後述) |
React Native の公式ドキュメントにも、iOS 9 以降は LSApplicationQueriesSchemes を Info.plist に書かないと canOpenURL() が常に false に解決される、と明記されています。Apple がプライバシー保護として導入した制限で、他アプリのインストール状況を探る行為を防ぐためのものです。
openURL 自体は宣言なしでも実行できる場合があります。ところが「インストールされていなければウェブ版を開く」という分岐を canOpenURL で書いていると、常に false になって「未インストール」と誤判定されます。ユーザーは TikTok を入れているのに、毎回 Safari のウェブ版へ飛ばされる——そういう体験になります。
修正 1: app.json に ios.infoPlist.LSApplicationQueriesSchemes を追加する
Rork は Expo の app config を踏襲しているので、Info.plist を直接いじる必要はありません。プロジェクトルートの app.json(または app.config.ts)に次のキーを足します。
{
"expo": {
"name": "MyApp",
"ios": {
"bundleIdentifier": "com.example.myapp",
"infoPlist": {
"LSApplicationQueriesSchemes": [
"tiktok",
"twitter",
"instagram",
"fb",
"fb-messenger",
"youtube",
"comgooglemaps",
"spotify",
"slack"
]
}
}
}
}app.config.ts を使っている場合は次のようになります。
import type { ExpoConfig } from "expo/config";
const config: ExpoConfig = {
name: "MyApp",
slug: "myapp",
ios: {
bundleIdentifier: "com.example.myapp",
infoPlist: {
LSApplicationQueriesSchemes: [
"tiktok",
"twitter",
"instagram",
"fb",
"fb-messenger",
"youtube",
"comgooglemaps",
"spotify",
"slack",
],
},
},
};
export default config;http / https や mailto・tel・sms のような標準スキームは、私の手元では宣言なしでも開けていました。以前は念のため並べていましたが、いまは「宣言が必要なものだけを書く」ほうが、あとから棚卸しするときに迷わないと感じています。
修正 2: 実際の URL Scheme を間違えない
ハマりやすいのが「アプリ名と Scheme 名が一致しない」ケースです。X を開きたいときに "x" と書いても期待どおりには動かず、私が確認した範囲では "twitter" を使っていました。ただしアプリ側の都合で将来変わる可能性があるため、出荷前に実機で canOpenURL を呼んで確かめる習慣をおすすめします。LINE の友だち追加は line:// より、Universal Link(https://line.me/R/ti/p/...)のほうが安全です。
代表的なスキームを表にまとめます。
| 開きたいもの | スキーム | 宣言 |
|---|---|---|
| TikTok | `tiktok` | 必要 |
| X(旧 Twitter) | `twitter` | 必要 |
| `instagram` | 必要 | |
| Facebook / Messenger | `fb` / `fb-messenger` | 必要 |
| YouTube | `youtube`(または `vnd.youtube`) | 必要 |
| Google マップ | `comgooglemaps` | 必要 |
| Spotify / Slack | `spotify` / `slack` | 必要 |
| LINE 友だち追加 | Universal Link(`https`) | 不要(`https` のため) |
これ以外のアプリは、各社の公式ドキュメントで「URL Scheme」を探すのがいちばん確実です。第三者がまとめた一覧はバージョンで古くなるため、引用する前に実機で確認しています。
修正 3: EAS Build を再ビルドしないと反映されない
ここでつまずく方がとても多いので、強調しておきます。app.json を編集しただけでは、開発中の Expo Go や既存の Development Build には反映されません。infoPlist は ネイティブビルドに焼き込まれる設定 なので、次のいずれかで作り直す必要があります。
# 開発用クライアントを作り直す
eas build --profile development --platform ios
# 本番ビルドを作り直す
eas build --profile production --platform ios
# ローカルで prebuild して実機へ
npx expo prebuild --clean
npx expo run:ios --deviceexpo prebuild --clean は ios/ ディレクトリを再生成します。独自に追加したファイルがある場合は、事前に差分を退避しておいてください。
修正 4: canOpenURL に頼らない書き方も持っておく
設定が入っても、コード側に「失敗時にウェブ版へ飛ばす」処理がなければ、スキームが変わった瞬間にまた同じ症状が出ます。React Native の公式ドキュメントによれば、canOpenURL は iOS でスキーム未宣言のとき reject されることがあり、openURL は開けるアプリがないとき reject されます。つまり どちらの失敗も try/catch で受けて、最後はウェブへ という形にしておけば、設定漏れがあってもユーザーは行き止まりになりません。
// utils/openExternalApp.ts
import { Linking } from "react-native";
type AppLink = {
/** ネイティブアプリを開く URL(Scheme または Universal Link) */
appUrl: string;
/** 開けなかった場合のウェブフォールバック */
webUrl: string;
};
export async function openExternalApp({ appUrl, webUrl }: AppLink): Promise<void> {
try {
if (await Linking.canOpenURL(appUrl)) {
await Linking.openURL(appUrl);
return;
}
} catch (e) {
// 未宣言スキームでの reject、または openURL の reject を受ける
if (__DEV__) {
console.warn("[openExternalApp] fallback to web", e);
}
}
await Linking.openURL(webUrl);
}
// 使い方
// await openExternalApp({
// appUrl: "tiktok://user?username=example",
// webUrl: "https://www.tiktok.com/@example",
// });try/catch を外側に置く点が肝です。ただし、このフォールバックは設定漏れを隠す働きもします。開発ビルドでは警告を必ず出し、本番で静かにウェブへ流れ続けることのないよう、確認の機会を残しておくと安心です。
修正 5: 回数の上限と、Android 側の別ルール
React Native の公式ドキュメントには、古い iOS 向けにリンクされたアプリが iOS 9 以降で動く場合、canOpenURL を呼べるのは 50 回まで で、超えると以降は常に false になり、再インストールやアップグレードでリセットされる、という記述があります。宣言できるスキームの件数の上限については、今回 Apple の公式ページを確認できなかったため、断定は避けます。ただ、ボタンを表示するたびに canOpenURL を呼んでいる画面は、回数の面でも無駄が出ます。起動時に一度だけ調べて結果を保持するほうが、私は扱いやすいと感じています。
もう一つ、Android 11(SDK 30)以降 を対象にすると、AndroidManifest.xml に <queries> でインテントを書かない限り、同じ種類の問い合わせが失敗することが公式ドキュメントに記されています。「Android は動いている」という観察は、対象 SDK によっては成り立ちません。
それでも canOpenURL が false の時に疑う 3 つのこと
ここまでやってもなお false が返るときは、次の 3 点を順に確認します。
- ビルドが古い: ビルド番号と、TestFlight で配信されているものが一致しているか。私は
app.jsonを直したのに古いビルドを再提出してしまい、半日を無駄にしたことがあります。 - EAS のキャッシュ:
eas buildに--clear-cacheを付けて再実行します。EAS はクラウド上でビルドするため、手元の Xcode の DerivedData を消しても効きません。 - スキームの綴り: 大文字小文字や
://の有無を含め、宣言した文字列と呼び出す URL が一致しているかを見直します。
次にやること
今日のうちに、お使いの Rork プロジェクトの app.json を開き、外部アプリへ飛ばしているスキームが LSApplicationQueriesSchemes に載っているかを一つずつ照らし合わせていただければと思います。載っていないものが一つでもあれば、iOS のユーザーは気づかないまま、毎回ウェブへ流れているかもしれません。