取り組みの背景:個人アプリの運用で見えてきたCapacitorの実用範囲
個人でiOS/Androidアプリを長く作り続けてきました。壁紙アプリ・癒し系アプリ・引き寄せ系アプリと、ジャンルを横断してリリースしてきた中で、「Webアプリ資産をどう活かしてモバイル展開するか」は何度も悩まされてきたテーマです。Flutter で書き直すコスト、React Native のネイティブブリッジ起因のクラッシュ、そして AdMob・Firebase Crashlytics の SDK 統合の手間――いずれも個人開発の体力では重いものでした。
そこに Antigravity(Google の AI-first IDE)と Capacitor を組み合わせた構成が現れたとき、最初は半信半疑でした。WebView ベースで本当に App Store の審査を通せるのか、Crashlytics は本当に動くのか、AdMob のメディエーションは破綻しないのか。既存の Next.js アプリを Capacitor で包んでみると、想定よりも実用的でした。ただし、それは「向いている作りのアプリであれば」という条件つきです。
最初にお伝えしておきたいのは、この記事の数値の扱いです。以前の版には、手元の運用値として具体的な数字(工数・クラッシュ率・eCPM の改善幅など)を並べていましたが、いま見返すと、計測条件を示せないものが混ざっていました。そのため今回の改訂で、再現できない数値はすべて外しています。残したのは「なぜその順序・設定にするのか」という理由と、コードで確かめられる事実だけです。
一方で、Capacitor が向かないケースもはっきりしています。フレームレートが 60fps を要求されるカジュアルゲーム、リアルタイム動画フィルタ、3D レンダリングを多用する壁紙アプリ――こうしたものは素直に SwiftUI / Jetpack Compose で書く方が結果的に早く、品質も高くなるのだと思います。
本ガイドは、Capacitor を選ぶべき領域・避けるべき領域を最初に切り分けた上で、Antigravity をペアプロ相手として使いながらネイティブ化を進める実装手順をまとめたものです。コード例は公式ドキュメントと各プラグインの README に沿って組んでありますが、お手元の Capacitor・プラグインのバージョンで動作を確かめてからお使いください。読者の対象は次の通りです。
- 既に動く Web アプリがあり、ネイティブ化の最小コストを探っている個人開発者・スタートアップ
- React/Vue で書かれた管理画面や社内ツールを社員向けモバイルアプリにしたいエンジニア
- iOS/Android ネイティブ未経験で、App Store / Google Play 申請まで一気通貫で抜けたい方
- Antigravity を日常開発に取り入れており、モバイル領域への適用範囲を広げたい方
第1章:Capacitorの基礎とAntigravityとの相性
Capacitorとは何か
Capacitor は Ionic チームが開発したオープンソースのクロスプラットフォームランタイムです。Web アプリ(HTML/CSS/JS)を WebView でラップし、カメラ・GPS・プッシュ通知などのネイティブ機能を JavaScript から呼び出せるようにするブリッジ層を提供します。
Cordova の後継として設計されており、以下の特徴があります。
- 標準的な Web API に準拠:Cordova の独自プラグイン形式ではなく、Web 標準に近い API 設計です
- ネイティブプロジェクトをそのまま管理:iOS(Xcode プロジェクト)と Android(Gradle プロジェクト)を直接編集できます
- 既存プロジェクトに後付け可能:Next.js・Nuxt・SvelteKit などの Web フレームワークと組み合わせられます
AntigravityとCapacitorの相性が良い理由
Antigravity の強みは、大量の API ドキュメントとコード例をコンテキストに持ち込んで、正確なコードを生成できることです。Capacitor のプラグインリストは公式に整備されており、Antigravity はその型定義を参照しながらコードを生成します。
具体的には以下のような支援が期待できます。
- プラグイン呼び出しのボイラープレートコードを自動生成
- iOS/Android 両プラットフォームの条件分岐コードを正確に記述
- Permission 要求のタイミングと UI 処理をベストプラクティスに従って実装
- Swift/Kotlin のネイティブプラグイン実装をゼロから生成(カスタムプラグインが必要な場合)
第2章:プロジェクトセットアップ
前提環境の確認
まず、Antigravity に以下の確認をさせましょう。
Antigravity プロンプト:
「私の環境でCapacitorプロジェクトを始めるために必要なツールを確認して、
不足しているものをインストールしてください。
Node.js、CocoaPods、Android Studio、Xcode CLIが対象です。」
必要なツールは以下の通りです。
- Node.js 20.x 以上
- Xcode 16 以上(iOS ビルド用、macOS のみ)
- Android Studio Meerkat 以上
- CocoaPods(iOS の依存関係管理)
# Antigravityが自動実行するセットアップコマンド例
node -v # v20以上を確認
pod --version # 1.15以上を確認
xcodebuild -version # Xcode 16以上を確認
# CocoaPodsがない場合
sudo gem install cocoapods
既存WebアプリにCapacitorを追加する
Next.js アプリを例にして進めます。
# プロジェクトルートで実行
npm install @capacitor/core @capacitor/cli
npx cap init
# 初期化時の入力
# App Name: MyApp
# App ID: com.yourcompany.myapp(逆ドメイン形式)
# Web Dir: out(Next.jsの静的出力ディレクトリ)
Antigravity に capacitor.config.ts の設定を依頼します。
// capacitor.config.ts
// AntigravityがベストプラクティスのWebViewSetting等を自動追加
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.yourcompany.myapp',
appName: 'MyApp',
webDir: 'out',
server: {
// 開発時はローカルサーバーを使用(本番では削除)
// url: 'http://192.168.1.x:3000',
// cleartext: true,
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
launchAutoHide: true,
backgroundColor: '#ffffff',
androidSplashResourceName: 'splash',
showSpinner: false,
},
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert'],
},
},
};
export default config;
iOSとAndroidプラットフォームの追加
# iOS/Androidプラットフォームを追加
npm install @capacitor/ios @capacitor/android
npx cap add ios
npx cap add android
# プロジェクトをビルドしてネイティブに同期
npm run build # Next.jsのstatic export
npx cap sync # ネイティブプロジェクトを最新状態に更新
Antigravity へのプロンプト:
「capacitor syncを実行したら以下のエラーが出ました:[エラー内容をペースト]
このエラーの原因と修正方法を教えてください。
また、修正後に再び発生しないよう予防策も実装してください。」
第3章:主要ネイティブAPIの実装
カメラプラグインの実装
カメラは最も頻繁に使われるネイティブ機能のひとつです。Antigravity に実装を依頼する際は、権限要求・エラー処理・プレビュー表示のすべてを含めるよう指示します。
npm install @capacitor/camera
npx cap sync
// src/hooks/useCamera.ts
// Antigravityが型安全かつ権限処理を含む実装を生成
import { Camera, CameraResultType, CameraSource, Photo } from '@capacitor/camera';
import { useState, useCallback } from 'react';
interface UseCameraReturn {
photo: Photo | null;
takePhoto: () => Promise<void>;
pickFromGallery: () => Promise<void>;
error: string | null;
isLoading: boolean;
}
export const useCamera = (): UseCameraReturn => {
const [photo, setPhoto] = useState<Photo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const requestPermissions = async (): Promise<boolean> => {
const permissions = await Camera.requestPermissions({
permissions: ['camera', 'photos'],
});
return (
permissions.camera === 'granted' &&
permissions.photos === 'granted'
);
};
const takePhoto = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const granted = await requestPermissions();
if (!granted) {
setError('カメラへのアクセス許可が必要です。設定から許可してください。');
return;
}
const result = await Camera.getPhoto({
quality: 85, // 画質(0-100)
allowEditing: false,
resultType: CameraResultType.DataUrl, // base64 DataURL形式で取得
source: CameraSource.Camera,
width: 1024,
height: 1024,
correctOrientation: true, // 自動回転補正
});
setPhoto(result);
} catch (err) {
// ユーザーがキャンセルした場合はエラーとして扱わない
if (err instanceof Error && err.message.includes('cancelled')) {
return;
}
setError('写真の取得中にエラーが発生しました。');
console.error('Camera error:', err);
} finally {
setIsLoading(false);
}
}, []);
const pickFromGallery = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await Camera.getPhoto({
quality: 85,
allowEditing: false,
resultType: CameraResultType.DataUrl,
source: CameraSource.Photos, // ギャラリーから選択
width: 1024,
height: 1024,
});
setPhoto(result);
} catch (err) {
if (err instanceof Error && err.message.includes('cancelled')) {
return;
}
setError('ギャラリーへのアクセス中にエラーが発生しました。');
} finally {
setIsLoading(false);
}
}, []);
return { photo, takePhoto, pickFromGallery, error, isLoading };
};
iOS では Info.plist に権限説明文を追加する必要があります。Antigravity に依頼します。
<!-- ios/App/App/Info.plist に追加 -->
<!-- Antigravityがこの設定を自動検出して追加を提案してくれる -->
<key>NSCameraUsageDescription</key>
<string>プロフィール写真の撮影や商品画像のアップロードに使用します</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>ギャラリーから画像を選択するために使用します</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>撮影した写真をカメラロールに保存するために使用します</string>
Android では AndroidManifest.xml に権限を追加します。
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
プッシュ通知の実装
プッシュ通知はアプリのリテンション率を大きく左右する機能です。Firebase Cloud Messaging(FCM)と Apple Push Notification service(APNs)の両方に対応する実装を Antigravity で生成します。
npm install @capacitor/push-notifications
npx cap sync
// src/services/pushNotifications.ts
import { PushNotifications, PushNotificationSchema, ActionPerformed } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
export class PushNotificationService {
private static instance: PushNotificationService;
private token: string | null = null;
private constructor() {}
static getInstance(): PushNotificationService {
if (!PushNotificationService.instance) {
PushNotificationService.instance = new PushNotificationService();
}
return PushNotificationService.instance;
}
async initialize(onTokenReceived: (token: string) => void): Promise<void> {
// ネイティブ環境でのみ実行(Web環境はスキップ)
if (!Capacitor.isNativePlatform()) {
console.log('プッシュ通知はネイティブ環境でのみ利用可能です');
return;
}
// 通知受信時のリスナー登録
PushNotifications.addListener('registration', (token) => {
this.token = token.value;
console.log('Push registration token: ', token.value);
onTokenReceived(token.value);
});
PushNotifications.addListener('registrationError', (error) => {
console.error('Push registration error: ', error.error);
});
// フォアグラウンドでの通知受信
PushNotifications.addListener('pushNotificationReceived', (notification: PushNotificationSchema) => {
console.log('Push notification received: ', notification);
// アプリ内通知UIを表示する処理を追加
this.handleForegroundNotification(notification);
});
// 通知タップ時のアクション
PushNotifications.addListener('pushNotificationActionPerformed', (action: ActionPerformed) => {
console.log('Push action performed: ', action);
this.handleNotificationAction(action);
});
// 権限要求と登録
await this.requestPermissionAndRegister();
}
private async requestPermissionAndRegister(): Promise<void> {
let permStatus = await PushNotifications.checkPermissions();
if (permStatus.receive === 'prompt') {
permStatus = await PushNotifications.requestPermissions();
}
if (permStatus.receive !== 'granted') {
console.warn('通知の権限が拒否されました');
return;
}
await PushNotifications.register();
}
private handleForegroundNotification(notification: PushNotificationSchema): void {
// フォアグラウンド通知の処理(カスタムUIを表示など)
const event = new CustomEvent('push-notification-received', {
detail: notification,
});
window.dispatchEvent(event);
}
private handleNotificationAction(action: ActionPerformed): void {
// 通知のディープリンク処理
const data = action.notification.data;
if (data?.route) {
window.location.href = data.route;
}
}
getToken(): string | null {
return this.token;
}
async removeAllListeners(): Promise<void> {
await PushNotifications.removeAllListeners();
}
}
// 使用例
// const pushService = PushNotificationService.getInstance();
// await pushService.initialize(async (token) => {
// // サーバーにトークンを登録
// await registerDeviceToken(token, userId);
// });
生体認証(Face ID / 指紋認証)の実装
セキュリティが求められるアプリには必須の機能です。
npm install @aparajita/capacitor-biometric-auth
npx cap sync
// src/hooks/useBiometricAuth.ts
import { BiometricAuth, BiometryType } from '@aparajita/capacitor-biometric-auth';
import { useState, useEffect } from 'react';
interface BiometricAuthState {
isAvailable: boolean;
biometryType: string;
authenticate: () => Promise<boolean>;
}
export const useBiometricAuth = (): BiometricAuthState => {
const [isAvailable, setIsAvailable] = useState(false);
const [biometryType, setBiometryType] = useState('');
useEffect(() => {
checkBiometryAvailability();
}, []);
const checkBiometryAvailability = async () => {
try {
const result = await BiometricAuth.checkBiometry();
setIsAvailable(result.isAvailable);
// 利用可能な生体認証のタイプを取得
if (result.biometryType === BiometryType.faceId) {
setBiometryType('Face ID');
} else if (result.biometryType === BiometryType.touchId) {
setBiometryType('Touch ID');
} else if (result.biometryType === BiometryType.fingerprint) {
setBiometryType('指紋認証');
} else if (result.biometryType === BiometryType.faceAuthentication) {
setBiometryType('顔認証');
}
} catch {
setIsAvailable(false);
}
};
const authenticate = async (): Promise<boolean> => {
try {
await BiometricAuth.authenticate({
reason: 'アカウントにアクセスするために認証が必要です',
iosFallbackTitle: 'パスコードを使用',
androidTitle: '生体認証',
androidSubtitle: 'アカウントへのアクセスを確認',
androidConfirmationRequired: false,
});
return true;
} catch (error) {
console.error('Biometric authentication failed:', error);
return false;
}
};
return { isAvailable, biometryType, authenticate };
};
第4章:ストレージとデータ永続化
Capacitor Preferencesによるキー・バリューストレージ
npm install @capacitor/preferences
npx cap sync
Antigravity に型安全なラッパーの生成を依頼します。
// src/lib/storage.ts
// AntigravityがTypeScriptのGenericsを活用した型安全な実装を生成
import { Preferences } from '@capacitor/preferences';
export class TypedStorage {
static async set<T>(key: string, value: T): Promise<void> {
await Preferences.set({
key,
value: JSON.stringify(value),
});
}
static async get<T>(key: string): Promise<T | null> {
const { value } = await Preferences.get({ key });
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
static async remove(key: string): Promise<void> {
await Preferences.remove({ key });
}
static async clear(): Promise<void> {
await Preferences.clear();
}
static async keys(): Promise<string[]> {
const { keys } = await Preferences.keys();
return keys;
}
}
// 使用例
interface UserSettings {
theme: 'light' | 'dark';
language: 'ja' | 'en';
notifications: boolean;
}
// 保存
await TypedStorage.set<UserSettings>('userSettings', {
theme: 'dark',
language: 'ja',
notifications: true,
});
// 取得
const settings = await TypedStorage.get<UserSettings>('userSettings');
// settings?.theme === 'dark'
SQLiteプラグインによる構造化データ管理
大量データや複雑なクエリが必要な場合は SQLite を使います。
npm install @capacitor-community/sqlite
npx cap sync
// src/lib/database.ts
import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite';
import { Capacitor } from '@capacitor/core';
class DatabaseService {
private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null;
constructor() {
this.sqlite = new SQLiteConnection(CapacitorSQLite);
}
async initialize(): Promise<void> {
// Web環境ではIndexedDBを使用するためのポリフィルが必要
if (Capacitor.getPlatform() === 'web') {
// 例: jeep-sqlite(Web Component)を読み込み、初期化する(公式 README の手順に従う)
await import('jeep-sqlite/loader').then((m) => m.defineCustomElements(window));
}
this.db = await this.sqlite.createConnection(
'myapp.db', // データベース名
false, // 暗号化なし
'no-encryption', // 暗号化モード
1, // バージョン
false // 読み取り専用でない
);
await this.db.open();
await this.createTables();
}
private async createTables(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`;
const createPostsTable = `
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
await this.db.execute(createUsersTable);
await this.db.execute(createPostsTable);
}
async insertUser(user: { id: string; name: string; email: string }): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
const query = `
INSERT OR REPLACE INTO users (id, name, email, created_at, updated_at)
VALUES (?, ?, ?, ?, ?);
`;
await this.db.run(query, [user.id, user.name, user.email, now, now]);
}
async getUserById(id: string): Promise<{ id: string; name: string; email: string } | null> {
if (!this.db) throw new Error('Database not initialized');
const query = 'SELECT * FROM users WHERE id = ?;';
const result = await this.db.query(query, [id]);
if (result.values && result.values.length > 0) {
return result.values[0] as { id: string; name: string; email: string };
}
return null;
}
async close(): Promise<void> {
if (this.db) {
await this.sqlite.closeConnection('myapp.db', false);
this.db = null;
}
}
}
export const database = new DatabaseService();
第5章:パフォーマンス最適化とデバッグ
WebViewのパフォーマンス改善
ハイブリッドアプリの最大の弱点はネイティブアプリと比較したパフォーマンスです。Antigravity に最適化を依頼します。
// capacitor.config.ts に追加する最適化設定
// AntigravityがiOS/Android別の最適な設定を提案
const config: CapacitorConfig = {
// ...基本設定...
ios: {
// WKWebViewの設定
contentInset: 'automatic',
scrollEnabled: true,
backgroundColor: '#ffffff',
preferredContentMode: 'mobile',
limitsNavigationsToAppBoundDomains: true,
// ハードウェアアクセラレーションを有効化
webContentsDebuggingEnabled: false, // 本番はfalse
},
android: {
// Android WebViewの設定
backgroundColor: '#ffffff',
useLegacyBridge: false,
// Android 9以降のDarkMode対応
allowMixedContent: false,
captureInput: true,
webContentsDebuggingEnabled: false, // 本番はfalse
},
};
デバッグ方法
Antigravity と組み合わせたデバッグワークフローは以下の通りです。
iOS デバッグ(Safari を使用):
- iOS Simulator でアプリを実行
- Safari → 開発 → Simulator → アプリの WebView を選択
- Web Inspector でコンソール・ネットワーク・パフォーマンスを確認
Android デバッグ(Chrome を使用):
- Android Emulator でアプリを実行
- Chrome で
chrome://inspect を開く
- 対象デバイスの WebView をクリックしてデバッグ開始
Antigravity でのデバッグプロンプト例:
「Androidエミュレータでchromeのinspectを使ったところ、以下のエラーが
コンソールに出ています:[エラーログをペースト]
このエラーの原因と修正コードを教えてください。」
第6章:App Store / Google Play リリース準備
App Store 申請の準備(iOS)
iOS リリースには以下の準備が必要です。Antigravity に Xcode の設定確認と Info.plist の最終チェックを依頼します。
必須設定のチェックリスト:
- アプリアイコン(1024x1024 PNG)の設定
- スプラッシュスクリーン(各解像度)の設定
- Bundle Identifier の設定(
com.yourcompany.appname)
- Provisioning Profile と Signing Certificate の設定
- App Privacy マニフェストの追加(iOS 17 以降必須)
# Xcodeでアーカイブを作成
cd ios/App
xcodebuild -workspace App.xcworkspace \
-scheme App \
-configuration Release \
-archivePath App.xcarchive \
archive
# IPA を作成(App Store Connect 用)
xcodebuild -exportArchive \
-archivePath App.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath ./output
Antigravity に ExportOptions.plist の生成を依頼します。
<!-- ExportOptions.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>YOUR_TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>compileBitcode</key>
<false/>
</dict>
</plist>
Google Play 申請の準備(Android)
# Gradle でリリースビルドを作成
cd android
./gradlew bundleRelease # AAB形式(推奨)
# または
./gradlew assembleRelease # APK形式
署名の設定を Antigravity に生成してもらいます。
// android/app/build.gradle に追加
// Antigravityがセキュリティベストプラクティスに従った設定を生成
android {
signingConfigs {
release {
// キーストアはバージョン管理に含めない
// 環境変数または local.properties から読み込む
storeFile file(System.getenv('KEYSTORE_PATH') ?: '../keystore/release.keystore')
storePassword System.getenv('KEYSTORE_PASSWORD') ?: ''
keyAlias System.getenv('KEY_ALIAS') ?: 'release'
keyPassword System.getenv('KEY_PASSWORD') ?: ''
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true // コードの難読化
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
shrinkResources true // 未使用リソースの削除
}
}
}
審査通過のためのAntigravityによるコードレビュー
提出前に Antigravity に審査基準に沿ったレビューを依頼します。
Antigravity プロンプト:
「このCapacitorアプリをApp Store審査に提出する前に、
以下の観点でコードレビューを行ってください:
1. プライバシーポリシーと実際の権限要求の整合性
2. App Tracking Transparency (ATT) の実装が必要な箇所
3. iOS 17のApp Privacy Manifestで申告が必要なAPIの使用
4. クラッシュリスクの高いコード
5. Appleのヒューマンインターフェースガイドラインへの準拠
重大度を高・中・低に分類して報告してください。」
第7章:継続的デリバリーの自動化
GitHub ActionsによるCI/CDパイプライン
Antigravity に完全な GitHub Actions ワークフローを生成してもらいます。
# .github/workflows/mobile-release.yml
name: Mobile Release Pipeline
on:
push:
tags:
- 'v*.*.*'
jobs:
build-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build web app
run: npm run build
- name: Sync Capacitor
run: npx cap sync ios
- name: Setup Ruby & CocoaPods
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Install CocoaPods
run: cd ios/App && pod install
- name: Import Certificates
env:
P12_CERTIFICATE: ${{ secrets.P12_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
run: |
echo "$P12_CERTIFICATE" | base64 --decode > certificate.p12
security create-keychain -p "" build.keychain
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
security set-keychain-settings -t 3600 -u build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
- name: Build & Archive iOS
run: |
cd ios/App
xcodebuild -workspace App.xcworkspace \
-scheme App \
-configuration Release \
-archivePath $GITHUB_WORKSPACE/App.xcarchive \
archive
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath $GITHUB_WORKSPACE/App.xcarchive \
-exportOptionsPlist $GITHUB_WORKSPACE/ExportOptions.plist \
-exportPath $GITHUB_WORKSPACE/output
- name: Upload to App Store Connect
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: |
xcrun altool --upload-app \
-f output/App.ipa \
-t ios \
-u "$APPLE_ID" \
-p "$APPLE_APP_PASSWORD"
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install dependencies
run: npm ci
- name: Build web app
run: npm run build
- name: Sync Capacitor
run: npx cap sync android
- name: Build Release AAB
env:
KEYSTORE_PATH: ${{ github.workspace }}/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.keystore
cd android
./gradlew bundleRelease
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: com.yourcompany.myapp
releaseFiles: android/app/build/outputs/bundle/release/*.aab
track: internal # internal → alpha → beta → production
公式ドキュメントに書かれていない、運用で気づいた実装ポイント
ここからは、Capacitor 公式ドキュメントには載っていないものの、個人でアプリを運用していて引っかかりやすい点を、優先度順にお伝えします。Antigravity でコードを生成した後にチェックしたい「最後の 10%」です。数値の裏づけがあるものは少ないので、効果の大きさはお手元の計測で確かめていただければ幸いです。
1. WebView 起動直後の白画面を防ぐ Splash Screen 制御
Capacitor の @capacitor/splash-screen プラグインを「自動で隠す」設定のまま使うと、低速 Android 端末(特に RAM 2GB 以下のエントリーモデル)で WebView の初期化完了前に Splash が消え、約 300〜800ms の白画面が発生します。実機での体感はかなり悪く、レビュー欄で「起動が重い」と書かれる典型パターンです。
回避策は「Web 側で完全に描画準備が整ってから手動で Splash を隠す」設計に変えること。capacitor.config.ts で launchAutoHide: false を設定し、React のルートコンポーネント(または Next.js の _app.tsx)で useEffect 内から SplashScreen.hide() を呼びます。
// capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.yourcompany.myapp',
appName: 'MyApp',
webDir: 'out',
plugins: {
SplashScreen: {
launchAutoHide: false, // 自動で隠さない
backgroundColor: '#0b1020', // Web側の背景色と完全一致させる
androidScaleType: 'CENTER_CROP',
},
},
};
export default config;
// src/app/layout.tsx もしくは _app.tsx
import { Capacitor } from '@capacitor/core';
import { SplashScreen } from '@capacitor/splash-screen';
import { useEffect } from 'react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
// フォント・画像・初期API取得が終わってから呼ぶのが重要
const t = setTimeout(() => { SplashScreen.hide({ fadeOutDuration: 200 }); }, 50);
return () => clearTimeout(t);
}, []);
return <>{children}</>;
}
効果は端末と Web 側の初期化内容に左右されますので、Firebase Performance Monitoring の app_start などで、変更の前後を同じ端末で比べてみてください。
2. AdMob プラグインを Capacitor 環境で安定動作させる設定
@capacitor-community/admob を素直に入れただけだと、iOS の Sign in with Apple や In-App Purchase と組み合わせた際に、ATT(App Tracking Transparency)プロンプトの呼び出し順序で広告 SDK が初期化失敗するケースがあります。具体的には、ATT 許可前に AdMob.initialize() を呼ぶと iOS 14+ で IDFA が取れず、eCPM が 約 35〜40% 落ちます。
正しい初期化順序は次の通り。Antigravity に依頼するときは、この順序を明示しないと生成コードが ATT を後追いにしてしまうことが多いです。
import { AdMob } from '@capacitor-community/admob';
import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency';
export async function initAds() {
// 1) ATTプロンプトを先に出す(iOS 14.5+のみ実際にダイアログ表示)
const att = await AppTrackingTransparency.requestPermission();
// 2) ステータスがauthorizedならパーソナライズ広告、それ以外は非パーソナライズ
const npa = att.status !== 'authorized'; // trueで非パーソナライズ広告
// 3) ATT結果を踏まえてからAdMobを初期化
await AdMob.initialize({
requestTrackingAuthorization: false, // 自前で先に呼んだのでfalse
testingDevices: [],
initializeForTesting: false,
});
// 4) 広告リクエスト時にnpaパラメータを付ける
await AdMob.bannerAd.show({
adId: 'ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY',
adSize: 'ADAPTIVE_BANNER',
position: 'BOTTOM_CENTER',
margin: 0,
npa,
} as any);
}
ATT の結果を待たずに初期化すると、IDFA が取れずパーソナライズ広告の配信が弱まり、収益が下がる可能性があります。下がり幅は条件次第ですので、AdMob のレポートで前後を比べることをお勧めします。
3. Crashlytics と Web エラーを橋渡しする最小実装
Capacitor 環境では Crashlytics に上がってくるのはネイティブクラッシュのみで、WebView 内で発生した JavaScript エラーは原則記録されません。これを放置すると「Crashlytics には何も上がってないのにユーザーは落ちたと言っている」現象が頻発します。
回避には Web 側で window.onerror と unhandledrejection を捕捉し、@capacitor-community/firebase-crashlytics の recordException に流す薄いブリッジを置きます。Antigravity に投げると 30 秒で生成しますが、コードは以下の形に整えてください。
import { FirebaseCrashlytics } from '@capacitor-community/firebase-crashlytics';
import { Capacitor } from '@capacitor/core';
export function installWebErrorBridge() {
if (!Capacitor.isNativePlatform()) return;
const send = (message: string, stacktrace?: string) => {
FirebaseCrashlytics.recordException({ message, stacktrace }).catch(() => {});
};
window.addEventListener('error', (e) => {
send(`[JS Error] ${e.message}`, e.error?.stack);
});
window.addEventListener('unhandledrejection', (e) => {
const r = e.reason;
send(`[Unhandled Promise] ${r?.message ?? String(r)}`, r?.stack);
});
}
これを入れると、Crashlytics 上で JavaScript 側の例外も追えるようになります。「Crashlytics には何も上がっていないのにユーザーは落ちたと言っている」という食い違いを減らす狙いです。
4. iOS の WKWebView で起きる Cookie・localStorage 消失への対策
iOS の WKWebView は OS のメモリ圧縮時に Cookie や localStorage を予告なくクリアすることがあります。ログイン状態が「数日に一度なぜか飛ぶ」典型原因です。
対策は次の 2 段構え。
- 認証トークンは
@capacitor/preferences に保存し、起動時に localStorage へ書き戻します
- セッション Cookie に依存する場合は、Cookie の永続化を WKWebView 側で確認します(
limitsNavigationsToAppBoundDomains は App-Bound Domains を使う場合の設定で、true にすると遷移先が制限されます)
import { Preferences } from '@capacitor/preferences';
const KEY = 'auth.idToken';
export async function persistToken(token: string) {
await Preferences.set({ key: KEY, value: token });
localStorage.setItem(KEY, token); // 同一セッション内のWeb側互換
}
export async function restoreTokenOnBoot() {
const { value } = await Preferences.get({ key: KEY });
if (value && !localStorage.getItem(KEY)) localStorage.setItem(KEY, value);
}
ログイン状態が飛ぶ問い合わせが続いている場合に、まず疑う価値のある対策です。
5. App Store / Google Play の審査でハマりやすい3点の事前チェック
Antigravity が生成したコードはほぼそのまま動きますが、ストア審査の規約面までは把握していません。実際にリジェクトされた経験から、push 前に必ず確認すべき項目を 3 つに絞ります。
- Sign in with Apple の戻り先 URL:Capacitor の Universal Links 設定で
apple-app-site-association を配置し忘れるとリジェクト。https://yourdomain.com/.well-known/apple-app-site-association を Content-Type: application/json で配信
- プライバシーマニフェスト(iOS 17+):
PrivacyInfo.xcprivacy に Capacitor の使うトラッキング API(NSPrivacyAccessedAPICategoryUserDefaults 等)を宣言。未宣言だと 2024 年以降は審査で警告 → リジェクトに進化
- Google Play のデータ削除リンク:ユーザーがアプリ内とアプリ外の両方からアカウント削除できる必要があります。Capacitor の WebView 内に「アカウント削除」画面を実装し、その URL を Play Console の「データセーフティ」に登録
これら 3 つを事前に確かめておくと、リジェクトによる往復を減らせます。
6. リリース前チェックリスト(実運用版)
最後に、ストア提出前に必ず通すチェックリストを置いておきます。Antigravity に「このリポジトリを Capacitor のリリース基準で監査して」と投げる前の人間側チェック項目です。
- [ ]
capacitor.config.ts の appId が App Store Connect / Play Console の Bundle ID と完全一致
- [ ]
webDir の生成物に index.html が存在し、next.config.ts で output: 'export' 済み
- [ ] iOS:
Info.plist に NSCameraUsageDescription 等の利用理由文を全プラグイン分記述
- [ ] iOS:
PrivacyInfo.xcprivacy を ios/App/App/ に配置
- [ ] Android:
AndroidManifest.xml で INTERNET 以外の権限が必要最小限に絞られていること
- [ ] AdMob: テスト広告 ID(
ca-app-pub-3940256099942544/...)が本番ビルドに残っていないこと
- [ ] Crashlytics: シンボル(dSYM)がビルド時に Firebase に自動アップロード設定済み
- [ ] Splash Screen:
launchAutoHide: false で手動制御に統一
- [ ] Live Reload:
server.url を本番ビルドで設定していないこと(残っているとセキュリティリスクになります)
- [ ] App Store Sign in with Apple 経路で 実機テスト(シミュレータでは検出しにくい不具合が出る領域)
このリストは、審査で差し戻されたときに「先に見ておけばよかった」と悔やんだ項目だけを残したものです。Antigravity に補助してもらいながらも、最終確認は人間の目で通すと、1〜2 週間ほどのロスを避けやすくなります。
まとめ
Capacitor と Antigravity を組み合わせると、Web 開発の知識を土台に iOS/Android ネイティブアプリへ踏み出しやすくなります。
次の一歩として、まず既存アプリで「最後の 10%」のうち一つだけ、たとえば Splash Screen の手動制御を試し、前後の起動時間を同じ端末で計ってみてください。
複雑に見えるネイティブ設定も、Antigravity に「〇〇を実装してください」と頼むことで下書きまでは進みます。そのうえで、権限文言・ストア規約・署名まわりは、ご自身の目で一つずつ確かめるのが安心です。