技術ログ

個人開発でCapacitorアプリをGoogle Playに出すまでに踏み抜いた5つの落とし穴

公開: 2026-07-04 · 著者: GRAMSHIFT

「Webができているなら1日で出せる」が甘かった — ネイティブ層の境界で踏んだ実際の罠

個人開発でCapacitorアプリをGoogle Playに出すまでに踏み抜いた5つの落とし穴

Webアプリ (HTML/CSS/JavaScript) の資産を持っているなら、Capacitorを使えば同じコードをAndroidアプリとしてGoogle Playに出せます。私はこの数週間で、自作のWebツール数本 (プラモの調色計算アプリ、ノノグラムのパズル、水草水槽の施肥計算アプリ) をCapacitorでラップしてPlayに提出してきました。「Webができているなら1日で出せる」と思っていたのですが、実際にはネイティブ特有の落とし穴を一つずつ踏み抜くことになりました。この記事では、私が実際に本番で踏んだ5つの罠と、その原因・修正を実コード込みで共有します。同じ構成 (Capacitor + 静的Web資産 + AdMobバナー) で個人開発している人の時間を、少しでも節約できればと思います。

落とし穴1: Filesystem.copy の directory は「コピー先」ではなく「コピー元」に効く

一番厄介だったのがこれです。水草アプリに「成長アルバム」機能 (カメラやギャラリーで撮った写真を端末内に保存して日付順に並べる) を実装したのですが、実機で「写真を取り込んでも何も起きない」という症状が出ました。エラーも出ない。ボタンを押しても無反応。デバッグして分かったのは、@capacitor/filesystemFilesystem.copy に渡す directory オプションの解釈を、私が完全に誤解していたことでした。

私は最初、こう書いていました。

// ❌ 写真が「無言で」失敗する書き方
await Filesystem.copy({
  from: photo.path,           // カメラが返す file:///... の絶対URL
  to: `photos/${name}.jpg`,
  directory: Directory.Data,  // 「保存先はDATA」のつもり
});

直感的には「directory: Directory.Dataコピー先をアプリのDATA領域にする指定」に見えます。ところが、Capacitorの実装 (AndroidネイティブのFilesystem.java) を実際に読んでみると、directoryfromto両方の相対パス解決に使われる仕様でした。つまり from に渡したカメラの絶対URL (file:///storage/...) までもが「DATA配下のパス」として再解決され、存在しないパスを指してコピーが必ず失敗していたのです。しかも失敗が例外ではなくPromiseのrejectで返り、私のコードがそれを握りつぶしていたので「無言の失敗」になっていました。

正しい書き方はこうです。宛先だけを toDirectory で指定し、from は絶対URLのまま渡します。

// ✅ 正しい書き方
try {
  await Filesystem.copy({
    from: photo.path,             // 絶対URLはそのまま
    to: `photos/${name}.jpg`,
    toDirectory: Directory.Data,  // 宛先だけを指定
  });
} catch (e) {
  // 握りつぶさず、ユーザーに失敗を伝える (キャンセルは静かに無視)
  showToast('写真の保存に失敗しました');
}

教訓は2つあります。第一に、Capacitorの Filesystem.copy で外部の絶対URLを扱うときは directory ではなく toDirectory を使う。第二に、ネイティブAPIのcatchを握りつぶさない。Web開発の癖でtry/catchの中を空にしていると、ネイティブ層の失敗が完全に見えなくなります。この2点は、今後Capacitorでファイルを扱うアプリを作るたびに効いてくる普遍的な罠だと思います。

落とし穴2: 「自分の端末では動く」新規インストール限定のブートクラッシュ

次に踏んだのは、開発中はまったく気づけない類のバグでした。アプリを更新して自分の端末で確認すると完璧に動く。ところが、まっさらな状態で新規インストールすると起動直後に真っ白な画面で死ぬ。原因は、永続化した状態オブジェクトの読み込み処理にありました。

function load() {
  const raw = localStorage.getItem('state');
  // ❌ def に新しく追加したキー customProducts を書き忘れた
  const def = { tanks: [], logs: [] };
  return { ...def, ...(raw ? JSON.parse(raw) : {}) };
}

// 描画側
state.customProducts.forEach(renderProduct); // 新規インストールでは undefined.forEach → TypeError

「マイ肥料 (ユーザーが自分で肥料を登録できる機能)」を後から足したとき、保存用のデフォルト値 defcustomProducts: [] を追加し忘れました。既存ユーザーはすでに保存済みの状態に customProducts が入っているので、{...def, ...saved} のマージで復活し、何事もなく動きます。だから開発機では絶対に再現しない。しかし新規インストールでは raw がnullなので def がそのまま使われ、customProductsundefined になって forEach で全画面が死にます。配布していたテスト用APKも同じ理由で壊れていました。

これを発見できたのは、ヘッドレスChromeでlocalStorageを空にした状態から起動させるE2Eスモークテストを書いていたからです。個人開発では「自分の端末で動いた=完成」にしがちですが、状態を持つアプリでは必ず「まっさらな初回起動」を自動テストの経路に含めるべきだと痛感しました。二重防御として、描画側も (state.customProducts || []).forEach(...) と防御的に書くようにしました。

落とし穴3: Capacitorの仮アイコンのままPlay審査に出して否認された

これはコードのバグではなく、リリースプロセスの罠です。あるアプリを審査に出したところ、Googleから否認が返ってきました。理由は「端末にインストールされるアプリのアイコンが、ストア掲載ページのアイコンと一致していない」。ストアには自作のキャラクター絵を登録していたのに、実機にインストールされるアイコンはCapacitorが生成したデフォルトの仮アイコン (青地に白いX) のままだったのです。Playは「ストアと実物の不一致」をユーザーを欺く可能性があるとみなして弾いてきます。

Capacitorは npx cap add android したときに、全density (mdpi〜xxxhdpi) のプレースホルダーアイコンを android/app/src/main/res/mipmap-*/ に置きます。これを差し替え忘れると、ビルドは通るしローカルでは動くので、審査に出すまで気づけません。修正には @capacitor/assets を使い、1枚のソース画像から全densityを一括生成しました。

# 1024x1024 のソースを assets/ に置いて実行
npx @capacitor/assets generate --android
# → mipmap-*/ の全アイコン、foreground/background、
#   splash (昼/夜) まで一括生成してくれる

教訓は「ビルドが通ること」と「審査を通ること」はまったく別」だということ。アイコン・スプラッシュ・アプリ名・パッケージ名は、Capacitorの初期値が残っていないかを提出前チェックリストに必ず入れるようにしました。

落とし穴4: AdMobのUMP同意フローは「最初のビルド前」に入れないと再リリース地獄

無料アプリをAdMobバナーで収益化する場合、EU圏のユーザーにはUMP (User Messaging Platform) による同意取得フローが必須です。私はこれを最初のビルドの段階でコードに組み込むことを強く勧めます。理由は、Playにいったんリリースしたあとで同意フローを後付けすると、バージョンを上げて再ビルド・再審査・再公開という一連のサイクルを丸ごともう一周することになるからです。個人開発でこの往復は地味に痛い。

// アプリ起動時、広告を初期化する前に同意を確認する
import { AdMob } from '@capacitor-community/admob';

async function initAds() {
  await AdMob.initialize();
  // UMP同意フォームを (必要な地域でだけ) 表示
  const info = await AdMob.requestConsentInfo();
  if (info.isConsentFormAvailable &&
      info.status === 'REQUIRED') {
    await AdMob.showConsentForm();
  }
  // 同意が済んでからバナーを出す
  await showBanner();
}

「同意フローはあとで足せばいい」と思いがちですが、広告の初期化コードと密結合するので、後付けは想像より配線が多くなります。広告を1行入れるより前に、同意フローの器だけでも先に置いておくのが結局いちばん速いです。

落とし穴5: 常時バナー広告とコンテンツの物理的な隔離

画面上部に固定表示するバナー広告 (TOP_CENTER) を入れると、ネイティブのバナーはWebViewの上に重なって描画されます。つまりCSSのz-indexやレイアウトの外側にいるので、放っておくとアプリのヘッダーや操作UIにバナーが被さります。誤タップ (ユーザーが操作しようとして広告を踏む) はAdMobのポリシー違反にもなりうるので、ここは丁寧にやる必要があります。

私がやったのは、バナーの高さぶんだけ body にpaddingを足して、コンテンツ全体を物理的に下へ逃がす方法です。

/* バナー表示中はコンテンツ全体を下げる */
body.has-topbanner {
  padding-top: 64px; /* バナーの実高 */
}
/* スクロールで sticky ヘッダーがバナーの「下」に潜る事故を防ぐ */
body.has-topbanner #appHeader {
  top: 64px;
}

特に position: sticky のヘッダーがあると、スクロール時にヘッダーがバナーの裏に潜り込む事故が起きます。これはヘッドレスChromeで幅360px (実機下限相当) を強制してスクショを撮る検証で見つけました。広告込みのレイアウトは、広告の高さを持つダミー要素を置いて実寸で確認するのが確実です。

まとめ: 「動いた」の検証を三段構えにする

Capacitorで静的Web資産をPlayに出す作業は、コードそのものより「Web開発の常識が通じないネイティブ層の境界」で時間を溶かします。今回踏んだ5つを振り返ると、どれも検証の仕方を変えれば事前に潰せたものでした。私は最終的に、検証を三段構えにして落ち着きました。第一にヘッドレスChromeでの実描画スクショ (レイアウト崩れ・広告被り)。第二にlocalStorageを空にした新規インストール相当のE2Eスモーク (初回起動クラッシュ・状態キー欠落)。第三に実機での目視 (ファイルAPI・カメラ・広告の実挙動)。この3つを通すまで「完成」とは言わないと決めたら、審査の往復が目に見えて減りました。個人開発は自分がテスターも兼ねるぶん、初回起動と実機の2つを意識的に検証経路へ入れることが、結局いちばんの近道だと思います。

よくある質問

CapacitorとFlutter/React Native、個人開発ならどれ?

既にWeb (HTML/CSS/JS) 資産があるならCapacitor一択。既存コードをほぼそのままラップでき、学習コストが最小。ゼロから高性能なネイティブUIを作るならFlutter等が候補だが、Web資産の再利用という観点ではCapacitorの立ち上がりが圧倒的に速い。

ビルドが通れば審査も通る?

まったく別。ビルド成功はコンパイルが通っただけで、Capacitorの仮アイコン・アプリ名・パッケージ名が初期値のまま残っていると、ストア掲載との不一致で審査否認されうる。アイコン/スプラッシュ/名称は提出前チェックリストに必ず入れること。

AdMobのUMP同意フローは後から足せる?

技術的には可能だが非推奨。広告初期化コードと密結合するため後付けは配線が増え、いったんPlayに公開したあとだとバージョンを上げて再ビルド・再審査・再公開の往復が発生する。最初のビルド前に器だけでも入れておくのが結局いちばん速い。