壁紙アプリ6本を1つのコードベースから出し分ける構成に切り替えたときのことです。ビルド設定を書き換えながら、ふと手が止まりました。
android.package の文字列が、6本のうち2本だけ命名の規則からずれていました。
その2本は、私がまだ「あとで整えればいい」と思っていた時期に公開したものです。整えることは、もうできませんでした。
アプリには名前が3種類あります。ストアに表示される名前、開発中に使う slug、そしてアプリそのものを識別する ID です。このうち公開したあとで変えられるのは、最初の1つだけです。
Rork は生成の時点で ID を自動的に埋めてくれます。動くものがすぐ手元に届くので、そのまま提出まで進んでしまいがちです。けれど提出を押した瞬間、その文字列は固定されます。
最初の1本を出す前の30分で確かめておきたいことを、順に整理していきます。
公開したあとで変えられるのは、表示名だけです
ストアに関わる項目を、変更できるものとできないもので分けると次のようになります。
| 項目 | App Store | Google Play | 公開後の変更 |
|---|---|---|---|
| 表示名(アプリ名) | 新しいバージョンの審査と一緒に変更 | ストア掲載情報から変更 | できます |
| アイコン・スクリーンショット | バージョン単位で差し替え | いつでも差し替え | できます |
| 説明文・カテゴリ | 審査と一緒に変更 | 随時変更 | できます |
| Bundle ID / パッケージ名 | App Store Connect に登録した時点で固定 | 公開した時点で固定 | できません |
| SKU | 登録時に決めたまま | 該当なし | できません |
| ストアの URL | 登録時に割り当てられる数値 ID | パッケージ名がそのまま URL に入ります | できません |
注目していただきたいのは最後の行です。Google Play のストア URL には、パッケージ名がそのまま文字列として現れます。つまり Android では、ID は開発者だけの都合ではなく、利用者の目に触れる情報でもあります。
App Store の Bundle ID は「公開後」ではなく「App Store Connect にアプリを登録した時点」で固定されます。まだ審査に出していなくても、登録を済ませていれば変えられません。ここは私も一度取り違えました。
なお署名鍵は少し事情が異なり、Google Play ではアップロード鍵の入れ替えを申請できます。鍵の不一致でつまずいた場合は、Rorkアプリを Google Play にアップロードできない署名キーエラーの直し方に手順をまとめてあります。
Rork が入れる ID は、雛形のままのことがあります
Rork が書き出したプロジェクトの app.json(もしくは app.config.ts)には、次のような記述が入っています。
{
"expo": {
"name": "wallpaper",
"slug": "wallpaper",
"ios": { "bundleIdentifier": "com.anonymous.wallpaper" },
"android": { "package": "com.anonymous.wallpaper" }
}
}com.anonymous. で始まる文字列は Expo の既定値です。生成物としては正しく、ビルドも通り、実機にも入ります。動いてしまうので、疑う理由がありません。
ここを自分のものに置き換える基準は、そう複雑ではありません。
- ドメインを持っているなら、逆順にしたものを先頭に置きます(
dolice.netならnet.dolice.) - 持っていないなら、実在するアカウント名に紐づけます(
io.github.<アカウント名>.など) - 末尾はプロダクトを表す短い語にします
避けたほうがよいのは、後から意味がずれる語を入れてしまうことです。free、v2、2026、ios といった語を末尾に足すと、無料版を有料化したときや作り直したときに、ID だけが過去の判断を指し続けます。私は一度これをやって、2本目以降の命名を引きずりました。
公開前に、機械で確かめます
目視での確認は、6本目あたりから確実に精度が落ちます。私は次のスクリプトを提出前に走らせるようにしました。依存パッケージはありません。Node があれば動きます。
// check-app-ids.mjs
import { readFileSync, existsSync } from "node:fs";
const RESERVED = new Set(["abstract","assert","boolean","break","byte","case","catch",
"char","class","const","continue","default","do","double","else","enum","extends",
"final","finally","float","for","goto","if","implements","import","instanceof","int",
"interface","long","native","new","package","private","protected","public","return",
"short","static","strictfp","super","switch","synchronized","this","throw","throws",
"transient","try","void","volatile","while"]);
const PLACEHOLDER = [/^com\.anonymous\./i, /^host\.exp\./i, /^com\.example\./i,
/\byourname\b/i, /\byourcompany\b/i, /\bmyapp\b/i, /\btest\b/i];
function readConfig(path) {
if (!existsSync(path)) return null;
const raw = JSON.parse(readFileSync(path, "utf8"));
return raw.expo ?? raw;
}
function checkAndroidPackage(pkg) {
const errs = [];
if (!pkg) return ["android.package が未設定です"];
const segs = pkg.split(".");
if (segs.length < 2) errs.push("ドット区切りが2つ未満です");
for (const s of segs) {
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(s)) errs.push(`セグメント "${s}" が英字始まりの条件を満たしません`);
if (RESERVED.has(s)) errs.push(`セグメント "${s}" は Java の予約語です`);
if (/[A-Z]/.test(s)) errs.push(`セグメント "${s}" に大文字が含まれます`);
}
return errs;
}
function checkBundleId(id) {
const errs = [];
if (!id) return ["ios.bundleIdentifier が未設定です"];
if (!/^[A-Za-z0-9.-]+$/.test(id)) errs.push("使用できない文字が含まれます");
if (id.split(".").length < 2) errs.push("ドット区切りが2つ未満です");
if (/_/.test(id)) errs.push("アンダースコアは使えません");
return errs;
}
const path = process.argv[2] ?? "app.json";
const cfg = readConfig(path);
if (!cfg) { console.error(`✗ ${path} が見つかりません`); process.exit(2); }
const ios = cfg.ios?.bundleIdentifier;
const android = cfg.android?.package;
const findings = [];
for (const e of checkBundleId(ios)) findings.push(["BLOCK", "ios.bundleIdentifier", e]);
for (const e of checkAndroidPackage(android)) findings.push(["BLOCK", "android.package", e]);
for (const [key, val] of [["ios.bundleIdentifier", ios], ["android.package", android]]) {
if (val && PLACEHOLDER.some((re) => re.test(val))) {
findings.push(["BLOCK", key, `"${val}" は雛形のままの疑いがあります`]);
}
}
if (ios && android && ios.toLowerCase() !== android.toLowerCase()) {
findings.push(["WARN", "ios/android", `ID が一致しません(iOS: ${ios} / Android: ${android})`]);
}
if (!cfg.name) findings.push(["WARN", "name", "表示名が未設定です"]);
if (!cfg.slug) findings.push(["WARN", "slug", "slug が未設定です"]);
console.log(`# ${path}`);
console.log(` ios : ${ios ?? "(なし)"}`);
console.log(` and : ${android ?? "(なし)"}`);
if (findings.length === 0) { console.log("✓ 公開前に固定してよい状態です"); process.exit(0); }
for (const [lv, key, msg] of findings) console.log(` ${lv === "BLOCK" ? "✗" : "!"} [${lv}] ${key}: ${msg}`);
process.exit(findings.some(([lv]) => lv === "BLOCK") ? 1 : 0);検査したいのは3つです。雛形のまま残っていないか、Android のパッケージ名に使えない語が入っていないか、iOS と Android で食い違っていないか。
2つ目について補足させてください。Expo は app.json の android.package を、Gradle の applicationId と、生成される Java / Kotlin のパッケージ名の両方に使います。そのため new や class のような予約語をセグメントに入れると、設定としては自然に見えるのにコンパイルで落ちます。原因がパッケージ名にあるとは思いにくい種類の失敗です。
手元の4パターンで実際に走らせた出力が、次のものです。
$ node check-app-ids.mjs a-default.json
# a-default.json
ios : com.anonymous.wallpaper
and : com.anonymous.wallpaper
✗ [BLOCK] ios.bundleIdentifier: "com.anonymous.wallpaper" は雛形のままの疑いがあります
✗ [BLOCK] android.package: "com.anonymous.wallpaper" は雛形のままの疑いがあります
exit=1
$ node check-app-ids.mjs b-mismatch.json
# b-mismatch.json
ios : net.dolice.calmwall
and : net.dolice.calm_wall
! [WARN] ios/android: ID が一致しません(iOS: net.dolice.calmwall / Android: net.dolice.calm_wall)
exit=0
$ node check-app-ids.mjs c-reserved.json
# c-reserved.json
ios : net.dolice.new.zentimer
and : net.dolice.new.zentimer
✗ [BLOCK] android.package: セグメント "new" は Java の予約語です
exit=1
$ node check-app-ids.mjs d-ok.json
# d-ok.json
ios : net.dolice.zentimer
and : net.dolice.zentimer
✓ 公開前に固定してよい状態です
exit=02番目を BLOCK ではなく WARN にしてある理由も書いておきます。calm_wall は Android では正当なパッケージ名です。アンダースコアが使えないのは iOS 側だけで、両者を揃えようとした結果としてずれただけ、という場合があります。機械が判断を代わりに下せない箇所なので、止めずに知らせるにとどめました。
exit の値を返すようにしてあるので、npm run のスクリプトやビルド前のフックにそのまま挟めます。
複数本を出すつもりなら、規則を先に決めます
1本で終わるつもりでも、うまくいけば2本目を作ることになります。そのときに効いてくるのが、最初の1本で決めた規則です。
私が6本で使っている形は <逆ドメイン>.<プロダクト略称> だけの2〜3セグメントです。プラットフォーム名も、版数も、配布形態も入れません。ID に情報を持たせるほど、情報が古くなったときに動かせない部分が増えていきます。
表示名のほうには、いくらでも情報を持たせて構いません。そちらは後から変えられるからです。変えられる場所に変わりうる情報を置き、変えられない場所には変わらない情報だけを置く、という切り分けが結局いちばん長持ちしました。
1つのコードベースから複数本を出し分ける具体的な構成は、壁紙アプリ6本を1つのコードベースから出し分ける — app.config.ts と EAS で組むホワイトラベル構成に、ビルドプロファイルの分け方まで含めて書いてあります。
すでに公開してしまった場合に取れる道
もう出してしまった、という方もいらっしゃると思います。取れる道は2つです。
そのまま使い続ける。 ID は Android のストア URL を除けば、利用者の目にほとんど触れません。表示名を変えれば見た目の問題は解消します。私の6本のうち、規則からずれた2本はこの判断で残してあります。数年後に見返す自分だけが気にする種類の不揃いだと考えたためです。
新しい ID で出し直す。 引き継げないものが多いことは、先に把握しておいたほうがよいと思います。ダウンロード数、評価とレビュー、ストア内の掲載順位の履歴、既存ユーザーの自動更新は、いずれも引き継げません。旧アプリから新アプリへ誘導する導線を自分で作る必要があります。コード・アセット・課金商品の定義は移せますが、課金商品はストア側で作り直しになります。
ひとつだけ補足させてください。アプリの登録を削除しても、ID は解放されません。App Store Connect では、一度アプリに割り当てた Bundle ID は登録を消したあとも自分のアカウントに予約されたまま残ります。Google Play も、内部テストを含むいずれかのトラックにビルドを上げた時点でパッケージ名が恒久的に予約されます。取り戻す道を探すより、新しい ID を決めるほうが早い場面です。
判断の分かれ目は、レビュー件数だと思っています。レビューがまだ数件であれば出し直しの損失は小さく、三桁に届いているなら、ID の不揃いを抱えたまま進むほうがおそらく合理的です。
公開前の30分でやること
app.jsonまたはapp.config.tsを開き、ios.bundleIdentifierとandroid.packageを確認します- 上のスクリプトを走らせ、BLOCK が0件になるまで直します
- 末尾に
freev22026iosのような、後から意味がずれる語が入っていないか読み返します - 2本目を出すときにどう伸ばすかを、1行でよいのでメモに残します
この4つを終えてから App Store Connect と Play Console にアプリを登録してください。順番が逆になると、直せるものが直せなくなります。
個人で開発していると、審査に出すまでの工程を全部ひとりで抱えることになります。だからこそ、機械に任せられる確認は機械に渡しておきたいと私は考えています。上のスクリプトは、その最初の一枚です。
最初の1本を出す日は、決めることが多すぎて、名前のような地味な項目が後回しになりがちです。私もそうでした。この記事が、その日の30分を守るのに役立てば嬉しく思います。お読みいただきありがとうございました。