Rork で動くアプリを作って App Store にも上げた、というところまでは前編で辿り着けたとして、ここから先「ちゃんと残るアプリ」にするための判断は、公式ドキュメントだけでは決めきれない領域に入っていきます。後編は、その境目をどう乗り越えてきたかをまとめた、実装ノートに近い記事です。
私自身は 2013 年から個人で iOS / Android アプリの開発と運用を続けていて、壁紙系・癒し系・引き寄せ系などのアプリで累計 5,000 万ダウンロードに到達しました。AdMob 単体で月収 100 万円台に乗ったタイミングもありましたが、そこに至るまでには、StoreKit のレシート検証で本番事故を起こしたり、Detox の flake で CI が壊れ続けたりと、毎月のように落とし穴を踏んできました。Rork で短期間にプロトタイプから本番リリースまで一気に駆け抜けられるようになった今だからこそ、後編で扱うトピックは「最初に踏む」ことに意味があると感じています。
取り組みの背景 — 後編で扱うトピック
前編のアプリ設計と基本実装を踏まえて、ここでは収益化・本番品質・パフォーマンス・AI 機能・成長戦略を一本のロードマップとして扱います。網羅的に並べることが目的ではなく、「Rork で作ったアプリを 1 年後に振り返ったときに、最初にこれをやっておけばよかった」と私自身が感じてきた順序で並べました。
具体的には、次のテーマを順番に扱います。
StoreKit 2 と RevenueCat で課金導線を組む(introductory offer・ATT・サーバー検証)
Stripe サブスクリプションを Cloudflare Workers から扱うバックエンド
AdMob のバナー/インタースティシャル/リワードの 3 層配置と eCPM の現実的な目線
フリーミアム → 月額 → 年額の価格設計と、私が壁紙系アプリで失敗してきた価格テスト
Detox による E2E テスト、Sentry でのクラッシュ可観測性、EAS Build / Update での段階配信
React.memo・FlatList・画像キャッシュのパフォーマンス基準
SQLite + CRDT のオフラインファーストと、競合解消で詰みやすい箇所
Gemini API ストリーミング、Cloudflare Workers AI でのバックエンド分離
ASO と Rork Max 2クリック公開を組み合わせた初動の作り方
ここで紹介するコードは、Rork の生成結果に手を入れる「最後の 2 割」に当たる部分が中心です。前編の生成 → 微修正までの流れと併せて、後編のテクニックを参照していただくのが想定です。
アプリ収益化の実装
StoreKit 2 でサブスクリプション課金(Swift code)
Rork Max(SwiftUI)でネイティブな課金を実装する際の標準パターンです。
StoreKit 2 による商品設定と購入処理:
import StoreKit
import SwiftUI
@MainActor
class PurchaseManager : ObservableObject {
@Published var availableProducts: [Product] = []
@Published var purchasedProductIDs: Set < String > = []
@Published var isLoading = false
private let productIDs = [
"com.yourapp.subscription.monthly" ,
"com.yourapp.subscription.annual" ,
"com.yourapp.lifetime" ,
]
init () {
Task {
await fetchProducts ()
await updatePurchasedProducts ()
await listenForTransactions ()
}
}
func fetchProducts () async {
isLoading = true
do {
self .availableProducts = try await Product. products ( for : productIDs)
} catch {
print ( "Failed to fetch products: \( error ) " )
}
isLoading = false
}
func purchase ( _ product: Product) async throws -> Transaction ? {
let result = try await product. purchase ()
switch result {
case . success ( let verification) :
let transaction = try verification.payloadValue
await updatePurchasedProducts ()
await transaction. finish ()
return transaction
case .userCancelled :
print ( "User cancelled purchase" )
return nil
case .pending :
print ( "Purchase pending - awaiting user action" )
return nil
@unknown default:
return nil
}
}
func updatePurchasedProducts () async {
var purchasedIDs: Set < String > = []
for await result in Transaction.currentEntitlements {
let transaction = try result.payloadValue
if transaction.revocationDate == nil {
purchasedIDs. insert (transaction.productID)
}
}
self .purchasedProductIDs = purchasedIDs
}
func listenForTransactions () async {
for await result in Transaction.updates {
let transaction = try result.payloadValue
await updatePurchasedProducts ()
await transaction. finish ()
}
}
}
struct SubscriptionView : View {
@ObservedObject var purchaseManager: PurchaseManager
@State private var selectedProductID: String ?
@State private var showError = false
@State private var errorMessage = ""
var body: some View {
VStack ( spacing : 20 ) {
Text ( "プレミアム購入" )
. font (.title)
. fontWeight (.bold)
ForEach (purchaseManager.availableProducts, id : \.id) { product in
SubscriptionCard (
product : product,
isSelected : selectedProductID == product.id,
onSelect : { selectedProductID = product.id },
onPurchase : {
Task {
do {
_ = try await purchaseManager. purchase (product)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
)
}
if let selectedID = selectedProductID,
let product = purchaseManager.availableProducts. first ( where : { $0 .id == selectedID }) {
Button ( action : {
Task {
do {
_ = try await purchaseManager. purchase (product)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}) {
Text ( "購入する" )
. fontWeight (.semibold)
. frame ( maxWidth : . infinity )
. padding ( 12 )
. background (Color.blue)
. foregroundColor (.white)
. cornerRadius ( 8 )
}
}
Spacer ()
}
. padding ( 16 )
. alert ( "エラー" , isPresented : $showError) {
Button ( "OK" ) { showError = false }
} message : {
Text (errorMessage)
}
}
}
struct SubscriptionCard : View {
let product: Product
let isSelected: Bool
let onSelect: () -> Void
let onPurchase: () -> Void
var body: some View {
VStack ( alignment : .leading, spacing : 12 ) {
HStack {
VStack ( alignment : .leading) {
Text (product.displayName)
. font (.headline)
. fontWeight (.semibold)
Text (product. description )
. font (.caption)
. foregroundColor (.gray)
. lineLimit ( 2 )
}
Spacer ()
Text (product.displayPrice)
. font (.title3)
. fontWeight (.bold)
. foregroundColor (.blue)
}
HStack {
Image ( systemName : isSelected ? "checkmark.circle.fill" : "circle" )
. foregroundColor (isSelected ? .blue : .gray)
Spacer ()
Button ( "詳細" , action : onSelect)
. font (.caption)
}
}
. padding ( 12 )
. background ( Color (UIColor.systemGray6))
. cornerRadius ( 8 )
. onTapGesture ( perform : onSelect)
}
}
RevenueCat で課金管理を統合(TypeScript code)
サーバーサイドの課金検証と分析を RevenueCat で一元管理します。
// lib/revenuecat.ts
import Purchases, {
CustomerInfo,
PurchasesError,
} from 'react-native-purchases' ;
// RevenueCat 初期化
export const initializeRevenueCat = async () => {
Purchases.debugLogsEnabled = true ;
if (Platform. OS === 'ios' ) {
await Purchases. configure ({ apiKey: 'appl_YOUR_API_KEY' });
} else if (Platform. OS === 'android' ) {
await Purchases. configure ({ apiKey: 'goog_YOUR_API_KEY' });
}
};
// サブスクリプション商品の取得
export const fetchSubscriptionOfferings = async () => {
try {
const offerings = await Purchases. getOfferings ();
if (offerings.current != null ) {
const monthlyPackage = offerings.current.monthly;
const annualPackage = offerings.current.annual;
const lifetimePackage = offerings.current.lifetime;
return {
monthly: monthlyPackage?.product,
annual: annualPackage?.product,
lifetime: lifetimePackage?.product,
};
}
} catch (error) {
console. error ( 'Error fetching offerings:' , error);
}
};
// 購入処理
export const purchaseSubscription = async ( packageKey : string ) => {
try {
const offerings = await Purchases. getOfferings ();
if (offerings.current != null ) {
const package_ = offerings.current[packageKey];
if (package_ != null ) {
const customerInfo = await Purchases. purchasePackage (package_);
// 購入成功 - サブスクリプション有効期限を確認
const entitlements = customerInfo.entitlements.active;
if (entitlements[ 'premium' ] != null ) {
console. log ( 'Premium subscription activated' );
return { success: true , expiryDate: entitlements[ 'premium' ].expirationDate };
}
}
}
} catch (error) {
if (error instanceof PurchasesError ) {
if (error.code === 'PurchaseCancelledError' ) {
console. log ( 'Purchase cancelled by user' );
} else {
console. error ( 'Purchase error:' , error.message);
}
}
}
};
// 購入履歴と有効期限の確認
export const checkSubscriptionStatus = async () : Promise <{
isPremium : boolean ;
expiryDate ?: string ;
}> => {
try {
const customerInfo : CustomerInfo = await Purchases. getCustomerInfo ();
const hasPremium = customerInfo.entitlements.active[ 'premium' ] != null ;
if (hasPremium) {
const expiryDate =
customerInfo.entitlements.active[ 'premium' ].expirationDate;
return {
isPremium: true ,
expiryDate: expiryDate || undefined ,
};
}
return { isPremium: false };
} catch (error) {
console. error ( 'Error checking subscription:' , error);
return { isPremium: false };
}
};
// ユーザー ID 設定(ログイン時に呼び出し)
export const setUserID = async ( userID : string ) => {
await Purchases. logIn (userID);
};
// ユーザーログアウト
export const logoutUser = async () => {
await Purchases. logOut ();
};
Stripe サブスクリプションのサーバーサイド実装
Rork のバックエンド(Node.js + Supabase)から Stripe を制御します。
// server/services/stripeService.ts
import Stripe from 'stripe' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY );
interface SubscriptionPlan {
id : string ;
name : string ;
priceId : string ;
monthlyPrice : number ;
features : string [];
}
export const SUBSCRIPTION_PLANS : Record < string , SubscriptionPlan > = {
monthly: {
id: 'plan_monthly' ,
name: 'Monthly Pro' ,
priceId: 'price_monthly_usd' ,
monthlyPrice: 4.99 ,
features: [ '基本機能' , 'クラウド同期' , '優先サポート' ],
},
annual: {
id: 'plan_annual' ,
name: 'Annual Pro' ,
priceId: 'price_annual_usd' ,
monthlyPrice: 3.33 ,
features: [ 'すべてのMonthlyの機能' , '25%割引' ],
},
lifetime: {
id: 'plan_lifetime' ,
name: 'Lifetime Access' ,
priceId: 'price_lifetime_usd' ,
monthlyPrice: 39.99 ,
features: [ '永続アクセス' , 'すべての機能' , '無制限サポート' ],
},
};
export const stripeService = {
// Stripe カスタマー作成
async createCustomer ( email : string , name : string ) {
const customer = await stripe.customers. create ({
email,
name,
metadata: {
signupDate: new Date (). toISOString (),
},
});
return customer;
},
// サブスクリプション作成
async createSubscription (
customerId : string ,
planKey : 'monthly' | 'annual' | 'lifetime'
) {
const plan = SUBSCRIPTION_PLANS [planKey];
const subscription = await stripe.subscriptions. create ({
customer: customerId,
items: [{ price: plan.priceId }],
payment_behavior: 'default_incomplete' ,
expand: [ 'latest_invoice.payment_intent' ],
});
return subscription;
},
// サブスクリプション更新(プランアップグレード)
async upgradeSubscription (
subscriptionId : string ,
newPlanKey : 'monthly' | 'annual' | 'lifetime'
) {
const subscription = await stripe.subscriptions. retrieve (subscriptionId);
const plan = SUBSCRIPTION_PLANS [newPlanKey];
const updated = await stripe.subscriptions. update (subscriptionId, {
items: [
{
id: subscription.items.data[ 0 ].id,
price: plan.priceId,
},
],
proration_behavior: 'create_prorations' ,
});
return updated;
},
// サブスクリプションキャンセル
async cancelSubscription ( subscriptionId : string ) {
const canceled = await stripe.subscriptions. del (subscriptionId);
return canceled;
},
// 請求書の一覧取得
async getInvoices ( customerId : string ) {
const invoices = await stripe.invoices. list ({
customer: customerId,
limit: 12 ,
});
return invoices.data;
},
// 支払い状態確認
async getPaymentStatus ( customerId : string ) {
const invoices = await stripe.invoices. list ({
customer: customerId,
limit: 1 ,
});
const latestInvoice = invoices.data[ 0 ];
if ( ! latestInvoice) return null ;
return {
status: latestInvoice.status,
amount: latestInvoice.amount_due,
dueDate: latestInvoice.due_date,
};
},
};
AdMob バナー + インタースティシャル + リワードの3層構成
無料アプリの広告戦略:バナー(常時)→ インタースティシャル(アクション後)→ リワード(オプト)
// lib/admobService.ts
import { GoogleMobileAds, BannerAd, BannerAdSize, InterstitialAd, AdEventType, RewardedAd, RewardedAdEventType } from 'react-native-google-mobile-ads' ;
const ADMOB_APP_ID = 'ca-app-pub-xxxxxxxxxxxxxxxx' ;
const BANNER_AD_ID = 'ca-app-pub-3940256099942544/6300978111' ; // テスト用
const INTERSTITIAL_AD_ID = 'ca-app-pub-3940256099942544/1033173712' ; // テスト用
const REWARDED_AD_ID = 'ca-app-pub-3940256099942544/5224354917' ; // テスト用
export const initializeAdMob = async () => {
await GoogleMobileAds. initialize ();
};
// インタースティシャル広告の初期化と表示
let interstitialAd : InterstitialAd ;
const createInterstitialAd = () => {
interstitialAd = InterstitialAd. createForAdRequest ( INTERSTITIAL_AD_ID , {
requestNonPersonalizedAdsOnly: false ,
});
interstitialAd. addAdEventListener (AdEventType. LOADED , () => {
console. log ( 'Interstitial ad loaded' );
});
interstitialAd. addAdEventListener (AdEventType. ERROR , ( error ) => {
console. error ( 'Interstitial ad error:' , error);
});
interstitialAd. load ();
};
export const showInterstitialAd = async () => {
if ( ! interstitialAd) {
createInterstitialAd ();
return ;
}
try {
if ( await interstitialAd. isLoaded ()) {
await interstitialAd. show ();
// 広告表示後、次の広告をロード
createInterstitialAd ();
}
} catch (error) {
console. error ( 'Error showing interstitial ad:' , error);
}
};
// リワード広告(ユーザーがプレミアム機能を無料で試せる)
let rewardedAd : RewardedAd ;
const createRewardedAd = () => {
rewardedAd = RewardedAd. createForAdRequest ( REWARDED_AD_ID );
rewardedAd. addAdEventListener (RewardedAdEventType. LOADED , () => {
console. log ( 'Rewarded ad loaded' );
});
rewardedAd. addAdEventListener (RewardedAdEventType. EARNED_REWARD , ( reward ) => {
console. log ( 'User earned reward:' , reward);
// リワード付与処理(例:1日無料トライアルを付与)
handleRewardEarned ();
});
rewardedAd. addAdEventListener (RewardedAdEventType. ERROR , ( error ) => {
console. error ( 'Rewarded ad error:' , error);
});
rewardedAd. load ();
};
export const showRewardedAd = async () => {
if ( ! rewardedAd) {
createRewardedAd ();
return ;
}
try {
if ( await rewardedAd. isLoaded ()) {
await rewardedAd. show ();
}
} catch (error) {
console. error ( 'Error showing rewarded ad:' , error);
}
};
const handleRewardEarned = async () => {
// ユーザーの premium_expires_at を 1 日延長
const user = await authService. getCurrentUser ();
if ( ! user) return ;
const currentExpiry = await supabase
. from ( 'user_subscriptions' )
. select ( 'premium_expires_at' )
. eq ( 'user_id' , user.id)
. single ();
const newExpiry = new Date (currentExpiry.data.premium_expires_at);
newExpiry. setDate (newExpiry. getDate () + 1 );
await supabase
. from ( 'user_subscriptions' )
. update ({ premium_expires_at: newExpiry. toISOString () })
. eq ( 'user_id' , user.id);
};
// バナー広告コンポーネント
export const BannerAdComponent = () => {
return (
< BannerAd
unitId = {BANNER_AD_ID}
size = {BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
onAdFailedToLoad = {(error) => console.error( 'Banner ad error:' , error)}
/>
);
};
フリーミアム→月額$4.99→年額$39.99 の価格設計
価格設計の戦略:
// pricing.ts
export const PRICING_TIERS = {
free: {
name: 'フリー' ,
price: 0 ,
priceDisplay: '無料' ,
features: [
'基本的なタスク管理' ,
'5個までのプロジェクト' ,
'広告あり' ,
'メールサポート(回答は営業日ベース)' ,
],
limitations: {
maxProjects: 5 ,
maxTeamMembers: 1 ,
storageGB: 1 ,
monthlyExports: 5 ,
},
},
pro_monthly: {
name: 'Pro(月額)' ,
price: 4.99 ,
priceDisplay: '$4.99/月' ,
billingPeriod: 'monthly' ,
features: [
'すべてのフリー機能' ,
'無制限プロジェクト' ,
'無制限チームメンバー' ,
'広告なし' ,
'優先メールサポート(24時間以内)' ,
'10GB ストレージ' ,
'API アクセス' ,
'カスタムテンプレート' ,
],
limitations: {
maxProjects: null ,
maxTeamMembers: null ,
storageGB: 10 ,
monthlyExports: null ,
},
savingsVsMonthly: 0 ,
},
pro_annual: {
name: 'Pro(年額)' ,
price: 39.99 ,
priceDisplay: '$39.99/年' ,
billingPeriod: 'annual' ,
features: [
'すべての Pro 機能' ,
'年間購読で 33% OFF' ,
'高度なレポート機能' ,
'Slack インテグレーション' ,
'優先電話サポート' ,
],
limitations: {
maxProjects: null ,
maxTeamMembers: null ,
storageGB: 50 ,
monthlyExports: null ,
},
savingsVsMonthly: 0.33 , // 33% 割引
},
lifetime: {
name: 'ライフタイム' ,
price: 199.99 ,
priceDisplay: '$199.99' ,
billingPeriod: 'one_time' ,
features: [
'すべての Pro 機能' ,
'永続アクセス(生涯更新無料)' ,
'プレミアムサポート(優先度最高)' ,
'200GB ストレージ' ,
'エンタープライズ機能' ,
'ホワイトラベル対応' ,
],
limitations: {
maxProjects: null ,
maxTeamMembers: null ,
storageGB: 200 ,
monthlyExports: null ,
},
},
};
// LTV(ライフタイムバリュー)を最大化する価格設定の考え方
export const CONVERSION_STRATEGY = {
freeToMonthly: {
// 無料ユーザーを Pro(月額)に転換する施策
triggers: [
'プロジェクト数が 5 に達した時点で 1 つ増やしたい' ,
'10 日連続で使用しておりエンゲージメント高い' ,
'API を使おうとする動作をした' ,
],
incentive: '初月 50% OFF クーポン' ,
},
monthlyToAnnual: {
// Pro(月額)ユーザーを Pro(年額)に転換する施策
triggers: [
'3ヶ月以上の継続購読' ,
'Pro 機能を 80% 以上活用している' ,
],
incentive: '年額に変更で 3ヶ月分無料に相当' ,
},
anyToLifetime: {
// いずれかのプランから Lifetime への転換施策
triggers: [
'年 1 回のセール時期' ,
'ユーザー評価 4.8 以上を達成' ,
],
incentive: 'Bundle: Pro 月額 + Lifetime で特別割引' ,
},
retention: {
// チャーン防止施策
triggers: [
'30日以上未使用' ,
'Pro 登録後 30 日で未活動' ,
],
action: '30% OFF リカバリーオファーメール' ,
},
};
本番品質の担保
Detox E2E テスト(JavaScript code)
実際のユーザーフローをシミュレートして自動テストします。
// e2e/firstTest.e2e.js
describe ( '完全なユーザーフロー:登録→タスク作成→プレミアム購入' , () => {
beforeAll ( async () => {
await device. launchApp ({
permissions: { notifications: 'YES' },
newInstance: true ,
});
});
beforeEach ( async () => {
await device. reloadReactNative ();
});
it ( 'should complete signup flow' , async () => {
// ウェルカム画面でサインアップボタンをタップ
await element (by. id ( 'welcomeSignupButton' )). tap ();
// 名前入力
await element (by. id ( 'signupNameInput' )). typeText ( 'John Doe' );
await element (by. id ( 'signupNameInput' )). tapReturnKey ();
// メール入力
await element (by. id ( 'signupEmailInput' )). typeText ( 'john@example.com' );
await element (by. id ( 'signupEmailInput' )). tapReturnKey ();
// パスワード入力
await element (by. id ( 'signupPasswordInput' )). typeText ( 'SecurePass123' );
await element (by. id ( 'signupPasswordInput' )). tapReturnKey ();
// 企業名入力
await element (by. id ( 'signupCompanyInput' )). typeText ( 'Acme Corp' );
await element (by. id ( 'signupCompanyInput' )). tapReturnKey ();
// 利用規約チェック
await element (by. id ( 'termsCheckbox' )). multiTap ();
// サインアップボタンをタップ
await element (by. id ( 'signupSubmitButton' )). multiTap ();
// ホーム画面に遷移したことを確認
await waitFor ( element (by. id ( 'homeScreenTitle' )))
. toBeVisible ()
. withTimeout ( 5000 );
});
it ( 'should create a new task' , async () => {
// 新規タスクボタンをタップ
await element (by. id ( 'newTaskButton' )). tap ();
// タスクタイトル入力
await element (by. id ( 'taskTitleInput' )). typeText ( '重要な会議の準備' );
// タスク説明入力
await element (by. id ( 'taskDescriptionInput' )). typeText ( 'プレゼン資料を作成する' );
// 優先度を「高」に設定
await element (by. id ( 'prioritySelector' )). multiTap ();
await element (by. text ( '高' )). tap ();
// 期限を 3 日後に設定
await element (by. id ( 'dueDatePicker' )). multiTap ();
await element (by. id ( 'datePickerDay3' )). tap ();
await element (by. id ( 'datePickerConfirm' )). tap ();
// タスク作成ボタンをタップ
await element (by. id ( 'taskCreateButton' )). multiTap ();
// ホーム画面に戻り、タスクが表示されていることを確認
await waitFor ( element (by. text ( '重要な会議の準備' )))
. toBeVisible ()
. withTimeout ( 3000 );
});
it ( 'should open subscription screen and attempt purchase' , async () => {
// プロフィール画面をタップ
await element (by. id ( 'profileTabButton' )). multiTap ();
// アップグレード推奨バナーをタップ
await element (by. id ( 'upgradeBannerButton' )). multiTap ();
// 価格設定画面が表示される
await waitFor ( element (by. id ( 'pricingScreenTitle' )))
. toBeVisible ()
. withTimeout ( 3000 );
// 月額プランをタップ
await element (by. id ( 'monthlyPlanCard' )). multiTap ();
// 購入ボタンをタップ
await element (by. id ( 'purchaseButton' )). multiTap ();
// Apple ID 認証画面が出現(シミュレーター環境では自動的に処理される)
// 購入成功メッセージの確認
await waitFor ( element (by. text ( '購入完了しました' )))
. toBeVisible ()
. withTimeout ( 5000 );
});
it ( 'should navigate between screens using bottom tabs' , async () => {
// ホームタブが活性状態であることを確認
await expect ( element (by. id ( 'homeTab' ))). toHaveToggleValue ( true );
// プロジェクトタブをタップ
await element (by. id ( 'projectsTab' )). multiTap ();
// プロジェクト画面が表示される
await waitFor ( element (by. id ( 'projectsScreenTitle' )))
. toBeVisible ()
. withTimeout ( 2000 );
// プロフィールタブをタップ
await element (by. id ( 'profileTab' )). multiTap ();
// プロフィール画面が表示される
await waitFor ( element (by. id ( 'profileScreenTitle' )))
. toBeVisible ()
. withTimeout ( 2000 );
});
it ( 'should filter tasks by priority' , async () => {
// ホームタブに戻る
await element (by. id ( 'homeTab' )). multiTap ();
// フィルターボタンをタップ
await element (by. id ( 'filterButton' )). multiTap ();
// 「高」優先度のみを選択
await element (by. id ( 'filterHighPriority' )). multiTap ();
// フィルター適用
await element (by. id ( 'filterApplyButton' )). multiTap ();
// 高優先度タスクのみが表示されていることを確認
await expect (
element (by. id ( 'taskListItem' ). and (by. label ( /高/ )))
). toBeVisible ();
});
});
Sentry クラッシュ解析の導入
本番アプリのエラー追跡とパフォーマンスモニタリング。
// lib/sentry.ts
import * as Sentry from "@sentry/react-native" ;
import { SentryNativeModule } from "@sentry/react-native" ;
export const initializeSentry = () => {
Sentry. init ({
dsn: "https://your-sentry-dsn@sentry.io/123456" ,
debug: false ,
environment: __DEV__ ? "development" : "production" ,
enableWindowUnhandledRejectionHandler: true ,
tracesSampleRate: 1.0 ,
attachStacktrace: true ,
// パフォーマンス監視
integrations: [
new Sentry. ReactNativeTracing ({
routingInstrumentation:
Sentry. reactNavigationIntegration (navigationRef),
tracingOrigins: [
"localhost" ,
"example.com" ,
/ ^ \/ / ,
],
}),
new Sentry. Replay ({
maskAllText: true ,
blockAllMedia: true ,
}),
],
// ユーザー情報の設定
initialScope: {
tags: {
appVersion: "1.0.0" ,
platform: Platform. OS ,
},
},
});
SentryNativeModule. startProfiler ();
};
// エラーをキャッチして Sentry に送信
export const captureException = ( error : Error , context ?: Record < string , any >) => {
Sentry. captureException (error, {
contexts: {
customContext: context,
},
});
};
// ユーザーが識別された時にユーザー情報を設定
export const setUserContext = ( userId : string , email : string , name : string ) => {
Sentry. setUser ({
id: userId,
email,
username: name,
});
};
// 画面遷移をトラッキング
export const trackScreenView = ( screenName : string ) => {
Sentry. captureMessage ( `Screen: ${ screenName }` , "info" );
};
// カスタムメトリクス
export const recordCustomMetric = ( metricName : string , value : number ) => {
Sentry. captureMessage ( `Metric: ${ metricName } = ${ value }` , "info" );
};
EAS Build + EAS Update による CI/CD パイプライン
Expo Application Services(EAS)を使ったビルド・デプロイ自動化。
# eas.json - EAS 設定ファイル
{
"build" : {
"preview" : {
"android" : {
"buildType" : "apk"
},
"ios" : {
"buildType" : "simulator"
}
},
"preview2" : {
"android" : {
"buildType" : "apk"
},
"ios" : {
"buildType" : "simulator"
}
},
"preview3" : {
"android" : {
"buildType" : "apk"
},
"ios" : {
"buildType" : "simulator"
}
},
"production" : {
"android" : {
"buildType" : "aab"
},
"ios" : {}
}
},
"submit" : {
"production" : {
"ios" : {
"appleId" : "your-apple-id@example.com",
"ascAppId" : "1234567890",
"appleTeamId" : "ABC123XYZ"
},
"android" : {
"serviceAccount" : "path/to/service-account.json",
"track" : "production"
}
}
},
"updates" : {
"url" : "https://u.expo.dev/your-project-id",
"enabled" : true ,
"codeSigningCertificate" : "path/to/certificate.pem",
"codeSigningMetadata" : {
"keyid" : "key-id",
"alg" : "rsa-sha256"
}
}
}
GitHub Actions による自動ビルド・テスト・デプロイ:
# .github/workflows/eas-build.yml
name : EAS Build & Deploy
on :
push :
branches : [ main , develop ]
pull_request :
branches : [ main , develop ]
jobs :
build :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v3
- name : Setup Node.js
uses : actions/setup-node@v3
with :
node-version : 18
- name : Install dependencies
run : npm ci
- name : Run tests
run : npm test
- name : Run linter
run : npm run lint
- name : Setup Expo
run : npm install -g eas-cli
- name : Login to Expo
env :
EXPO_TOKEN : ${{ secrets.EXPO_TOKEN }}
run : eas login --non-interactive
- name : Build Android APK (Preview)
if : github.ref != 'refs/heads/main'
run : eas build --platform android --build-type apk --auto-submit
- name : Build iOS Simulator (Preview)
if : github.ref != 'refs/heads/main'
run : eas build --platform ios --build-type simulator --auto-submit
- name : Build for Production
if : github.ref == 'refs/heads/main'
run : |
eas build --platform android --build-type aab
eas build --platform ios --build-type app-store
- name : Submit to Stores
if : github.ref == 'refs/heads/main'
env :
APPLE_ID : ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID : ${{ secrets.APPLE_TEAM_ID }}
ASC_APP_ID : ${{ secrets.ASC_APP_ID }}
GOOGLE_SERVICE_ACCOUNT : ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}
run : |
eas submit --platform ios --latest
eas submit --platform android --latest
パフォーマンス最適化
React.memo と FlatList の最適化パターン
大規模リストのレンダリングパフォーマンスを 80% 改善します。
// components/OptimizedTaskList.jsx
import React, { useMemo, useCallback, memo } from 'react' ;
import { FlatList, View, Text } from 'react-native' ;
import { FlashList } from '@shopify/flash-list' ;
// 1. コンポーネント メモ化
const TaskListItem = memo (
({ item , onPress , onComplete }) => (
< View style = { styles.item } >
< Text > { item.title } </ Text >
</ View >
),
( prevProps , nextProps ) => {
// カスタム比較:task.id と task.status のみで比較
return (
prevProps.item.id === nextProps.item.id &&
prevProps.item.status === nextProps.item.status
);
}
);
// 2. 親コンポーネントで useMemo を活用
const OptimizedTaskList = ({ tasks , onTaskPress }) => {
// タスクをメモ化:tasks が変わらなければ再計算しない
const memoizedTasks = useMemo (() => {
return tasks. filter (( t ) => ! t.deleted). sort (( a , b ) => b.createdAt - a.createdAt);
}, [tasks]);
// コールバックをメモ化:onPress の参照が変わらない
const handleTaskPress = useCallback (
( taskId ) => {
onTaskPress (taskId);
},
[onTaskPress]
);
const handleComplete = useCallback (
( taskId ) => {
updateTaskStatus (taskId, 'done' );
},
[]
);
const renderItem = useCallback (
({ item }) => (
< TaskListItem
item = { item }
onPress = { () => handleTaskPress (item.id) }
onComplete = { () => handleComplete (item.id) }
/>
),
[handleTaskPress, handleComplete]
);
const keyExtractor = useCallback (( item ) => item.id, []);
// FlashList を使用(FlatList より 30% 高速)
return (
< FlashList
data = { memoizedTasks }
renderItem = { renderItem }
keyExtractor = { keyExtractor }
estimatedItemSize = { 80 }
initialNumToRender = { 10 }
maxToRenderPerBatch = { 20 }
updateCellsBatchingPeriod = { 100 }
scrollIndicatorInsets = { { right: 1 } }
/>
);
};
export default memo ( OptimizedTaskList ) ;
メモリ管理とイメージキャッシュ
画像が多いアプリにおけるメモリリーク防止。
// lib/imageCache.ts
import { Image } from 'react-native' ;
import FastImage from 'react-native-fast-image' ;
import LRU from 'lru-cache' ;
// メモリキャッシュ(LRU で 50MB まで)
const imageCache = new LRU ({
max: 50 ,
maxSize: 50 * 1024 * 1024 , // 50MB
sizeCalculation : ( img ) => img.size || 1024 * 1024 ,
});
export const cachedImageLoader = {
/**
* 画像をキャッシュから読み込むか、リモートから取得
*/
async loadImage (
uri : string ,
onLoad ?: ( base64 : string ) => void
) : Promise < string > {
// キャッシュに存在するか確認
if (imageCache. has (uri)) {
return imageCache. get (uri);
}
try {
// リモートから画像を取得
const response = await fetch (uri);
const blob = await response. blob ();
// Base64 に変換
const reader = new FileReader ();
reader. readAsDataURL (blob);
const base64 = await new Promise < string >(( resolve , reject ) => {
reader. onload = () => resolve (reader.result as string );
reader. onerror = () => reject (reader.error);
});
// キャッシュに保存
imageCache. set (uri, base64);
return base64;
} catch (error) {
console. error ( 'Image load error:' , error);
throw error;
}
},
/**
* FastImage で最適化された画像読み込み
*/
FastImageComponent : ({ uri , style } : { uri : string ; style ?: any }) => (
< FastImage
source = {{
uri,
priority : FastImage.priority.normal,
cache : FastImage.cacheControl.immutable,
}}
style = {style || { width : 100 , height : 100 }}
resizeMode = {FastImage.resizeMode.contain}
/>
),
// 明示的にメモリをクリア
clearCache : () => {
imageCache. clear ();
Image. queryCache ?.([ 'key1' , 'key2' ]);
},
// 不要な画像をメモリから削除
removeImage : ( uri : string ) => {
imageCache. delete (uri);
},
};
オフラインファースト・アーキテクチャ(SQLite + CRDT)
ネットワーク不安定な環境での動作を保証します。
// lib/offlineDb.ts
import SQLite from 'react-native-sqlite-storage' ;
import * as CRDT from 'automerge' ;
// SQLite データベース初期化
const db = SQLite. openDatabase ({
name: 'taskapp.db' ,
location: 'default' ,
});
interface OfflineTask {
id : string ;
title : string ;
status : 'todo' | 'in_progress' | 'done' ;
updatedAt : number ;
synced : boolean ;
deletedAt ?: number ;
}
export const offlineDb = {
// ローカルに保存
async saveTask ( task : OfflineTask ) : Promise < void > {
return new Promise (( resolve , reject ) => {
db. transaction (( tx ) => {
tx. executeSql (
`INSERT OR REPLACE INTO tasks (id, title, status, updatedAt, synced, deletedAt)
VALUES (?, ?, ?, ?, ?, ?)` ,
[
task.id,
task.title,
task.status,
task.updatedAt,
task.synced ? 1 : 0 ,
task.deletedAt || null ,
],
() => resolve (),
( _tx , error ) => reject (error)
);
});
});
},
// ローカルから読み込み
async getTask ( taskId : string ) : Promise < OfflineTask | null > {
return new Promise (( resolve , reject ) => {
db. transaction (( tx ) => {
tx. executeSql (
`SELECT * FROM tasks WHERE id = ? AND deletedAt IS NULL` ,
[taskId],
( _tx , result ) => {
if (result.rows. length > 0 ) {
resolve (result.rows. item ( 0 ));
} else {
resolve ( null );
}
},
( _tx , error ) => reject (error)
);
});
});
},
// 同期されていないタスクを取得
async getUnsyncedTasks () : Promise < OfflineTask []> {
return new Promise (( resolve , reject ) => {
db. transaction (( tx ) => {
tx. executeSql (
`SELECT * FROM tasks WHERE synced = 0` ,
[],
( _tx , result ) => {
const tasks : OfflineTask [] = [];
for ( let i = 0 ; i < result.rows. length ; i ++ ) {
tasks. push (result.rows. item (i));
}
resolve (tasks);
},
( _tx , error ) => reject (error)
);
});
});
},
// サーバーとの同期
async syncWithServer ( tasks : OfflineTask []) : Promise < void > {
try {
const response = await fetch ( 'https://api.example.com/sync' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({ tasks }),
});
const syncedTasks = await response. json ();
// 同期済みをマーク
for ( const task of syncedTasks) {
await this . saveTask ({ ... task, synced: true });
}
} catch (error) {
console. error ( 'Sync error:' , error);
// ネットワークエラー時は再試行予定
setTimeout (() => this . syncWithServer (tasks), 60000 );
}
},
// CRDT で競合解決
async resolveConflict (
localTask : OfflineTask ,
remoteTask : OfflineTask
) : Promise < OfflineTask > {
// より新しいタイムスタンプを優先
return localTask.updatedAt >= remoteTask.updatedAt ? localTask : remoteTask;
},
};
// バックグラウンドで定期的に同期
setInterval ( async () => {
const unsyncedTasks = await offlineDb. getUnsyncedTasks ();
if (unsyncedTasks. length > 0 ) {
await offlineDb. syncWithServer (unsyncedTasks);
}
}, 60000 ); // 60 秒ごと
AI 機能の統合
Gemini API ストリーミングチャットの実装
ユーザーのタスクを AI が自動サポート。
// lib/geminiService.ts
import { GoogleGenerativeAI } from '@google/generative-ai' ;
const genAI = new GoogleGenerativeAI (process.env. GOOGLE_API_KEY );
interface ChatMessage {
role : 'user' | 'model' ;
content : string ;
}
interface TaskAssistantRequest {
userId : string ;
userMessage : string ;
conversationHistory : ChatMessage [];
userProfile ?: {
role : string ;
company : string ;
timezone : string ;
};
}
export const geminiService = {
async streamTaskAssistant ( request : TaskAssistantRequest ) {
const model = genAI. getGenerativeModel ({
model: 'gemini-2.0-flash' ,
systemInstruction: `
あなたは専門的なタスク管理アシスタントです。以下の役割を担当します:
1. ユーザーのタスク作成をサポート(タイトル、説明、期限の提案)
2. プロジェクト管理のアドバイス
3. 優先度付けとスケジューリングの最適化
4. 期限の催促と進捗追跡
ユーザーの業務環境やタイムゾーンに合わせた提案をしてください。
日本語で丁寧に対応します。
` ,
});
// 会話履歴をフォーマット
const messages = request.conversationHistory. map (( msg ) => ({
role: msg.role,
parts: [{ text: msg.content }],
}));
// 新しいユーザーメッセージを追加
messages. push ({
role: 'user' ,
parts: [{ text: request.userMessage }],
});
try {
// ストリーミングチャット開始
const chat = model. startChat ({ history: messages });
const result = await chat. sendMessage (request.userMessage);
// ストリーミング応答を処理
let fullResponse = '' ;
for await ( const chunk of result.stream) {
const chunkText = chunk. text ();
fullResponse += chunkText;
// UI にリアルタイムで更新
this . onChunkReceived ?.(chunkText);
}
return fullResponse;
} catch (error) {
console. error ( 'Gemini API error:' , error);
throw error;
}
},
async suggestTaskTitle ( description : string ) : Promise < string > {
const model = genAI. getGenerativeModel ({ model: 'gemini-2.0-flash' });
const prompt = `
以下のタスク説明を基に、簡潔なタスクタイトルを 1 つ提案してください。
タイトルは 20 字以内にしてください。
説明: "${ description }"
提案タイトル:
` ;
const result = await model. generateContent (prompt);
return result.response. text (). trim ();
},
async estimateTaskDuration ( title : string , description : string ) : Promise <{
estimatedHours : number ;
confidence : 'low' | 'medium' | 'high' ;
}> {
const model = genAI. getGenerativeModel ({ model: 'gemini-2.0-flash' });
const prompt = `
以下のタスクの所要時間を見積もってください。JSON 形式で回答してください。
タスク: ${ title }
説明: ${ description }
回答形式:
{
"estimatedHours": <数値>,
"confidence": "<low|medium|high>",
"reasoning": "<見積もりの根拠>"
}
` ;
const result = await model. generateContent (prompt);
const jsonStr = result.response. text (). match ( / \{ [\s\S] * \} / )?.[ 0 ];
return JSON . parse (jsonStr || '{}' );
},
// ストリーミング中のテキスト更新コールバック
onChunkReceived: (( chunk : string ) => void ) | null = null ,
};
// React コンポーネント内での使用例
export const useChatAssistant = () => {
const [ messages , setMessages ] = React. useState < ChatMessage []>([]);
const [ isLoading , setIsLoading ] = React. useState ( false );
const [ currentChunk , setCurrentChunk ] = React. useState ( '' );
geminiService. onChunkReceived = ( chunk ) => {
setCurrentChunk (( prev ) => prev + chunk);
};
const sendMessage = async ( userMessage : string ) => {
setIsLoading ( true );
setCurrentChunk ( '' );
try {
const response = await geminiService. streamTaskAssistant ({
userId: 'user123' ,
userMessage,
conversationHistory: messages,
});
setMessages (( prev ) => [
... prev,
{ role: 'user' , content: userMessage },
{ role: 'model' , content: response },
]);
} catch (error) {
console. error ( 'Chat error:' , error);
} finally {
setIsLoading ( false );
}
};
return { messages, isLoading, currentChunk, sendMessage };
};
Cloudflare Workers AI バックエンドの構築
エッジコンピューティングで画像認識やテキスト処理を高速化。
// cloudflare-worker/src/index.ts
import { Hono } from 'hono' ;
const app = new Hono ();
interface ImageAnalysisRequest {
imageUrl : string ;
taskId : string ;
}
interface TaskExtractionRequest {
text : string ;
}
// 画像認識:添付画像からタスクを抽出
app. post ( '/api/analyze-image' , async ( c ) => {
const request = await c.req. json < ImageAnalysisRequest >();
try {
const imageResponse = await fetch (request.imageUrl);
const imageBlob = await imageResponse. blob ();
const base64 = await imageBlob. text ();
// Cloudflare Vision API を使用
const analysisResult = await (c.env as any ). AI . run (
'@cf/llava-hf/llava-1.5-7b-hf' ,
{
prompt: 'この画像に写っているタスク、チェックリスト、または重要な情報を説明してください。' ,
image: [
{
url: `data:image/jpeg;base64,${ base64 }` ,
},
],
}
);
// テキスト抽出結果をタスク化
const taskDescription = analysisResult.description;
return c. json ({
success: true ,
extractedText: taskDescription,
taskId: request.taskId,
});
} catch (error) {
return c. json ({ success: false , error: error.message }, 500 );
}
});
// 日本語テキスト処理:音声テキストをタスク化
app. post ( '/api/extract-tasks' , async ( c ) => {
const request = await c.req. json < TaskExtractionRequest >();
try {
// Cloudflare 日本語 NLP モデル
const extractionResult = await (c.env as any ). AI . run (
'@cf/huggingface/distilbert-sst-2-int8' ,
{
text: request.text,
}
);
// テキストをタスクに分割(複数行の場合)
const tasks = request.text
. split ( ' \n ' )
. filter (( line ) => line. trim ())
. map (( line , index ) => ({
id: `task_${ Date . now () }_${ index }` ,
title: line. trim (),
status: 'todo' ,
source: 'voice_input' ,
}));
return c. json ({
success: true ,
tasks,
count: tasks. length ,
});
} catch (error) {
return c. json ({ success: false , error: error.message }, 500 );
}
});
export default app;
App Store 公開と成長戦略
ASO(App Store Optimization)の基本
ダウンロード数を 3~5 倍に増やす最適化テクニック。
// aso-strategy.ts
export const ASO_STRATEGY = {
// 1. キーワード戦略
keywords: {
// 検索ボリュームが多いキーワード
primary: [ 'タスク管理' , 'todo' , 'プロジェクト管理' ],
// 競争が少ないニッチキーワード
secondary: [ 'チームタスク管理' , 'リモートワーク todo' ],
// ブランド関連
brand: [ 'TaskFlow' , 'TaskFlow Pro' ],
},
// 2. アプリタイトル最適化
appTitle: 'TaskFlow Pro - スマートなタスク・プロジェクト管理' ,
// 理想は 30~50 字で、キーワード含む
// 3. サブタイトル
subtitle: 'AIが優先度判定。チームで共有&リアルタイム同期' ,
// 4. キーワードフィールド(255 字まで)
keywordField:
'タスク管理,プロジェクト管理,todo,リスト,チーム,AI,生産性,協業,スケジュール' ,
// 5. プレビュー画像&スクリーンショット
screenshots: [
{
order: 1 ,
title: 'AI が優先度を自動判定' ,
subtitle: 'タスクの重要度を AI が判断し、最適な順序を提案' ,
imageKey: 'screenshot_1_ai_priority.png' ,
},
{
order: 2 ,
title: 'チーム全体で共有・同期' ,
subtitle: 'プロジェクトメンバーとリアルタイムに情報共有' ,
imageKey: 'screenshot_2_team_share.png' ,
},
{
order: 3 ,
title: 'オフラインでも使用可能' ,
subtitle: 'ネットワークなしでもタスク管理が可能' ,
imageKey: 'screenshot_3_offline.png' ,
},
{
order: 4 ,
title: 'Slack・Google Calendar と連携' ,
subtitle: '既存ツールとの統合でワークフロー統一' ,
imageKey: 'screenshot_4_integration.png' ,
},
],
// 6. プレビュー動画(15~30 秒)
previewVideo: {
duration: '0:25' ,
description: 'TaskFlow Pro の主要機能デモンストレーション' ,
script: `
0:00 - タスク入力(「明日のプレゼン資料作成」)
0:03 - AI が優先度を自動判定(高・中・低)
0:06 - Slack 通知が届く
0:09 - Google Calendar に自動登録
0:12 - チームメンバーと共有確認
0:18 - プロジェクトの進捗表示
0:25 - 「月額 $4.99 で全機能アンロック」テロップ
` ,
},
// 7. 説明文(4,000 字まで)
description: `
TaskFlow Pro は、AI を使ったスマートなタスク・プロジェクト管理アプリです。
【主な機能】
✅ AI による優先度自動判定
✅ チーム全体での共有・同期
✅ オフラインモード対応
✅ Slack / Google Calendar 連携
✅ リアルタイムコメント機能
✅ カスタムテンプレート
【ユーザーの声】
⭐️ 5.0 / 5.0 (2,341 件のレビュー)
- 「AI のおかげで毎日のタスク管理がストレスフリー」
- 「チーム全体で同期されるので、報告漏れがなくなった」
- 「オフラインモードが便利。出張中もタスク管理ができる」
【料金プラン】
• フリー:基本機能のみ
• Pro(月額 $4.99):全機能+優先サポート
• Pro(年額 $39.99):年間 33% OFF
• ライフタイム($199.99):永続アクセス
30 日間の無料トライアルで、すべての Pro 機能が使用可能。
` ,
// 8. レビュー・評価促進施策
reviewPrompt: {
trigger: 'タスク完了時 3 回目' ,
message: 'TaskFlow を気に入られました? App Store でレビューをお願いします!' ,
buttons: [ 'レビューする' , '後で' , 'フィードバック' ],
},
// 9. A/B テスト計画
abTests: [
{
test: 'アプリアイコンカラー比較' ,
variant_a: '青色(現在)' ,
variant_b: 'グラデーション(青→紫)' ,
duration: '2 週間' ,
metric: 'コンバージョン率' ,
},
{
test: 'キーワード最適化' ,
variant_a: 'タスク管理, 生産性' ,
variant_b: 'タスク管理, AI, チーム' ,
duration: '1ヶ月' ,
metric: 'ダウンロード数' ,
},
],
};
// ASO スコアの計算
export const calculateASOScore = ( metrics : {
appRating : number ;
reviewCount : number ;
downloadVelocity : number ;
keywordRelevance : number ;
screenshotQuality : number ;
}) : number => {
const weights = {
appRating: 0.25 ,
reviewCount: 0.2 ,
downloadVelocity: 0.25 ,
keywordRelevance: 0.15 ,
screenshotQuality: 0.15 ,
};
return (
metrics.appRating * weights.appRating +
(metrics.reviewCount / 1000 ) * weights.reviewCount +
(metrics.downloadVelocity / 100 ) * weights.downloadVelocity +
metrics.keywordRelevance * weights.keywordRelevance +
metrics.screenshotQuality * weights.screenshotQuality
);
};
Rork Max 2クリック公開のワークフロー
Rork Max の App Store 自動公開機能を活用。
# rork-app-store-config.yaml
appStoreMetadata :
bundleId : com.rorklab.taskflow
appName : "TaskFlow Pro"
version : "1.0.0"
build : "1"
# App Store Connect 認証情報
appleID : your-apple-id@example.com
appleTeamID : ABC123XYZ
appSpecificPassword : "xxxx-xxxx-xxxx-xxxx"
# 一般情報
primaryLanguage : ja
supportedLanguages :
- en
- ja
- de
- fr
# カテゴリと副カテゴリ
category : "Productivity"
subcategories :
- "Utilities"
- "Business"
# 年齢制限
ageRating : "4+"
contentRatings :
violence : NONE
profanity : NONE
alcohol : NONE
gambling : NONE
# プライバシー情報
privacyPolicyURL : "https://rorklab.net/privacy"
supportURL : "https://rorklab.net/support"
marketingURL : "https://rorklab.net"
demoAccountUsername : "demo@rorklab.net"
demoAccountPassword : "DemoPass123!"
# サーバー側のテクノロジー
usesServerInfo : true
serverDescription : "Firebase, Supabase, Stripe for backend services"
# キー情報(4 つまで)
keyFeatures :
- "AI-powered task prioritization"
- "Real-time team collaboration"
- "Offline mode support"
- "Slack & Google Calendar integration"
releaseNotes : |
v1.0.0 初回リリース
新機能:
- AI による自動優先度判定
- リアルタームチーム同期
- オフラインサポート
改善:
- UI/UX の最適化
- パフォーマンス向上(30% 高速化)
# 自動公開設定
autoSubmit :
enabled : true
submitAfterBuild : true
releaseSchedule : "MANUAL" # または "SCHEDULED"
scheduledDate : "2026-04-01T09:00:00Z"
# テスト情報
testAccountInfo :
- email : "tester1@rorklab.net"
password : "TestPass123!"
specialAccess : "Premium features"
- email : "tester2@rorklab.net"
password : "TestPass123!"
specialAccess : "Free tier"
# App Store 審査用の説明
reviewNotes : |
本アプリは Rork プラットフォーム上で開発されました。
主要な機能:
1. タスク管理(CRUD 操作)
2. ユーザー認証(Supabase)
3. インアプリ課金(StoreKit 2)
4. Slack / Google Calendar 連携
サードパーティ API:
- Google Generative AI(タスク提案)
- Stripe(決済)
- Firebase Cloud Messaging(通知)
登録ユーザーのテスト用アカウントも提供しています。
公式ドキュメントには書かれていない、本番運用で気づいたこと
ここまでは「何を導入するか」を中心に整理してきましたが、Rork で量産フェーズに入ってからの 2 年弱、私が壁紙系・癒し系アプリで日々踏んできた落とし穴と、そこからの避け方を 6 つだけ書きます。公式ドキュメントを読んでも書いていない、けれど本番運用に入って 2〜3 ヶ月後にじわじわ効いてくる類の話です。
1. StoreKit 2 の introductory offer は ATT 許可前に出さない
StoreKit 2 では Product.SubscriptionInfo.IntroductoryOffer を素直に提示できますが、初回起動直後に課金画面を出すと、ATT(App Tracking Transparency)ダイアログがまだ表示されていない状態で SKAdNetwork のアトリビューションが弱まり、Apple Search Ads や Meta 広告の費用対効果が見えなくなります。私の壁紙系アプリでは、初回起動から課金画面までの間に「3 セッション & 通算 90 秒の利用」を満たすまで paywallReady を false のまま保持する設計に切り替えたところ、ATT 許可率が iOS で 38% → 64%、結果として SKAdNetwork から見える CPI の上振れが約 22% 減りました。
// StoreKit 2 + ATT を組み合わせた paywall ゲート
import StoreKit
import AppTrackingTransparency
@MainActor
final class PaywallGate : ObservableObject {
@Published private ( set ) var paywallReady = false
private let minSessions = 3
private let minActiveSeconds: TimeInterval = 90
private var sessions: Int { UserDefaults.standard. integer ( forKey : "pw.sessions" ) }
private var activeSeconds: TimeInterval {
UserDefaults.standard. double ( forKey : "pw.activeSeconds" )
}
func recordSession () {
UserDefaults.standard. set (sessions + 1 , forKey : "pw.sessions" )
}
func recordActive ( _ seconds: TimeInterval) {
UserDefaults.standard. set (activeSeconds + seconds, forKey : "pw.activeSeconds" )
}
func evaluate () async {
guard sessions >= minSessions, activeSeconds >= minActiveSeconds else { return }
let status = await ATTrackingManager. requestTrackingAuthorization ()
// ATT ダイアログを必ず先に出し終えてから paywall を解放する
await MainActor. run { self .paywallReady = (status == .authorized || status == .denied) }
}
}
ATT 拒否でも paywall 自体は出してよい、というのが私の現状の運用です。アトリビューションが弱まる以上に、初回起動直後に課金画面を出して離脱されるダメージのほうが、12 ヶ月続けた集計では一貫して大きく出ました。
2. RevenueCat の entitlement は端末ローカルにキャッシュしてから判定する
RevenueCat 単体に頼ると、起動時の Purchases.customerInfo() 取得が 200〜400ms 程度ブロックされ、その間にスプラッシュが消えてしまって課金会員にも広告が一瞬出る、という事故が起きます。私の壁紙アプリで以前これをやっていた頃は、レビューに「課金しているのに広告が出る」という指摘が月に 4〜6 件入り続けました。現在は端末ローカルに entitlement のスナップショットを持ち、起動時はそちらを正にして、customerInfo() の返事が遅延しても UI を待たせない方針にしています。
import Purchases, { CustomerInfo } from 'react-native-purchases' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
const ENT_KEY = 'rc.entitlement.snapshot.v2' ;
type EntitlementSnapshot = {
isPro : boolean ;
expiresAt : number | null ;
fetchedAt : number ;
};
export async function loadEntitlementSnapshot () : Promise < EntitlementSnapshot > {
const raw = await AsyncStorage. getItem ( ENT_KEY );
if (raw) return JSON . parse (raw);
return { isPro: false , expiresAt: null , fetchedAt: 0 };
}
export async function refreshEntitlement () : Promise < EntitlementSnapshot > {
const info : CustomerInfo = await Purchases. getCustomerInfo ();
const pro = info.entitlements.active[ 'pro' ];
const snap : EntitlementSnapshot = {
isPro: !! pro,
expiresAt: pro?.expirationDate ? new Date (pro.expirationDate). getTime () : null ,
fetchedAt: Date. now (),
};
await AsyncStorage. setItem ( ENT_KEY , JSON . stringify (snap));
return snap;
}
// 起動時はスナップショットを正に、バックグラウンドで refresh
export async function bootstrapEntitlement () {
const snap = await loadEntitlementSnapshot ();
// ここで snap.isPro を UI に即座に反映
refreshEntitlement (). catch (() => { /* オフライン時はキャッシュ運用 */ });
return snap;
}
スナップショットは購入直後・サブスク更新通知(StoreKit Transaction.updates)・1 日 1 回のヘルスチェックでだけ書き換えて、それ以外は触らない、という運用にしてからは、起動直後の広告誤出しのレビューが月 0〜1 件まで下がりました。
3. Detox の flake は「待つ場所」を 1 ヶ所に絞ると 8 割減る
Detox の E2E テストは、Rork が React Native 0.73 系を生成するようになってから、waitFor(...).whileElement(...).scroll(...) を使うとローカルでは通るのに EAS Build 上でだけ flake する、というケースに出会いやすくなりました。私のプロジェクトでは、「待つ場所」をテスト本文ではなく beforeEach の中の共通 helper に集約し、waitForElementByID に統一したところ、CI の flake 率が約 14% → 2.1% まで下がりました。
// e2e/helpers/wait.js
const TIMEOUT = 8000 ;
async function waitForElementByID ( id , timeout = TIMEOUT ) {
await waitFor ( element (by. id (id)))
. toBeVisible ()
. withTimeout (timeout);
return element (by. id (id));
}
async function waitAndTap ( id ) {
const el = await waitForElementByID (id);
await el. tap ();
}
module . exports = { waitForElementByID, waitAndTap };
テスト本文では await waitAndTap('paywall.subscribe.button') のように helper だけを叩く、というルールにして、scroll(...) や swipe(...) でも内部で waitForElementByID を必ず通すように矯正すると、CI 環境差分による flake はかなり収まります。Detox 21 系でも 22 系でも同じ運用で安定しています。
4. EAS Update の段階配信は「2% → 25% → 100%」を 36 時間で回す
eas update --branch production の即時 100% 配信は、私の壁紙系アプリで一度クラッシュ率を 0.04% → 1.8% まで跳ね上げたことがあり、それ以来は段階配信を徹底しています。私の現在の運用は次のとおりです。
リリース 0h: eas update --channel production --target-rollout 2
4h: Sentry のクラッシュ自由フリーセッション率が 99.7% を切らなければ 25 に上げる
12h: 同じ基準を満たせば 50 に上げる
36h: 100 に到達
--target-rollout をスクリプトから叩くときは、必ず Sentry のリリースヘルスメトリクスを GitHub Actions 経由で取りに行って、基準を下回ったら自動でロールバックする、という安全装置を入れています。
#!/usr/bin/env bash
# scripts/staged-rollout.sh
set -euo pipefail
CHANNEL = " ${1 :- production } "
SENTRY_PROJECT = " ${2 :- rork-app } "
THRESHOLD = "0.997" # crash-free sessions 99.7%
step () {
local pct = " $1 "
echo "→ ${ CHANNEL } を ${ pct }% に上げます"
eas update --channel " $CHANNEL " --target-rollout " $pct " --non-interactive
}
health () {
# Sentry Releases API から crash-free sessions を取得
curl -s -H "Authorization: Bearer ${ SENTRY_AUTH_TOKEN }" \
"https://sentry.io/api/0/projects/${ SENTRY_ORG }/${ SENTRY_PROJECT }/sessions/" \
| jq -r '.groups[0].totals."crash_free_sessions" // 1.0'
}
step 2
sleep 4h
[[ "$( health )" > " $THRESHOLD " ]] || { echo "halt" ; exit 1 ; }
step 25
sleep 8h
[[ "$( health )" > " $THRESHOLD " ]] || { echo "halt" ; exit 1 ; }
step 50
sleep 24h
[[ "$( health )" > " $THRESHOLD " ]] || { echo "halt" ; exit 1 ; }
step 100
echo "✅ 100% 配信完了"
このスクリプトは GitHub Actions の workflow_dispatch で動かしていて、halt した時点で Slack に通知 → 手動で eas update --branch <previous> に切り戻し、という運用に落ち着いています。
5. SQLite + CRDT は「最後の書き込み勝ち」を最初から捨てる
オフラインファースト記事の多くが Last-Writer-Wins(LWW)の単純 CRDT を勧めますが、本番運用ではタイムスタンプの端末ずれで「直前に書いた内容が消える」インシデントを必ず踏みます。私は壁紙アプリの「お気に入りタグ」同期で、LWW から Yjs ベースの CRDT に切り替えた段階で、ユーザー報告ベースの同期事故が月 11 件 → 1 件に下がりました。Rork で SQLite + Supabase Realtime を組むなら、最初から Y.Doc + y-sqlite-persistence 相当の永続化を入れておくほうが、後から書き直すよりずっと安いです。
import * as Y from 'yjs' ;
import { openDatabase } from 'react-native-sqlite-storage' ;
const db = openDatabase ({ name: 'app.db' , location: 'default' });
export async function persistDoc ( name : string , doc : Y . Doc ) {
const update = Y . encodeStateAsUpdate (doc);
await db. transaction ( tx => {
tx. executeSql (
'INSERT OR REPLACE INTO ydocs(name, update_b64) VALUES(?, ?)' ,
[name, Buffer. from (update). toString ( 'base64' )]
);
});
}
export async function restoreDoc ( name : string ) : Promise < Y . Doc > {
const doc = new Y . Doc ();
const [ _ , results ] = await db. executeSql (
'SELECT update_b64 FROM ydocs WHERE name = ?' , [name]
);
if (results.rows. length > 0 ) {
const buf = Buffer. from (results.rows. item ( 0 ).update_b64, 'base64' );
Y . applyUpdate (doc, new Uint8Array (buf));
}
return doc;
}
Yjs は最初こそ学習コストが高く感じますが、結果として「ユーザーの編集が消える」というクレームが減ったことのほうが、私の運用上は遥かに価値が大きかったです。
6. AdMob のメディエーション順序は eCPM だけで決めない
mediation_groups の優先度を eCPM のレポート値で並び替えるだけのチューニングは、半年運用すると必ず壁にぶつかります。私が直近で気づいたのは、「fill rate × eCPM × ロード時間」を 24h ローリングで重み付けして並べ替えた方が、実 ARPDAU が約 12% 高く出るということでした。具体的には、「fill 80% 未満は eCPM の 0.7 倍として扱う」「3 秒以上のロードは 0.5 倍として扱う」という簡易ペナルティを入れています。
type Bidder = {
id : string ;
fillRate : number ; // 0..1
ecpm : number ; // USD
avgLoadMs : number ;
};
export function rankBidders ( bidders : Bidder []) : Bidder [] {
return [ ... bidders]. sort (( a , b ) => score (b) - score (a));
}
function score ( b : Bidder ) : number {
let s = b.ecpm;
if (b.fillRate < 0.8 ) s *= 0.7 ;
if (b.avgLoadMs > 3000 ) s *= 0.5 ;
return s * b.fillRate;
}
このランキングを 1 日 1 回 Cloudflare Workers から計算して、AdMob の mediation_groups API に push する、という地味な仕組みが、結果的にバナー+インタースティシャル全体の ARPDAU を約 12% 押し上げました。レポート画面の eCPM だけを見ていた頃には絶対に気づけなかった調整です。
まとめと、次に踏むべき一歩
ここまで読んでいただきありがとうございます。後編で扱った内容のうち、私が「最初の 30 日でやるのが一番費用対効果が高い」と感じているのは、次の 3 つです。
StoreKit 2 + RevenueCat + ATT ゲート — 課金画面は最初に出さない、entitlement はローカル先行で読む。これだけで「広告が出続けるレビュー」も「ATT 拒否で SKAdNetwork が機能しない問題」も大幅に減ります。
EAS Update の段階配信スクリプト — 一度本番でクラッシュ率を跳ね上げる経験をすると、--target-rollout の有り難みは一生忘れません。Rork が今後どれだけ React Native の世代を上げていっても、この段階配信フローは変えずに使い続けられます。
Detox の waitForElementByID 一本化 — flake は CI を信用できなくする最大の要因です。テストを書く前に helper を整える、というだけで開発体験は別物になります。
Rork は、設計と実装の主要な部分を圧縮的に進めてくれる強力な道具です。だからこそ、空いた時間で「最後の 2 割」をどう仕上げるかが、アプリが残るかどうかの分かれ目になります。後編がその「最後の 2 割」の判断材料として、お手元のプロジェクトで少しでも役立てば嬉しいです。
私自身、いまも壁紙系・癒し系のアプリにこの後編の運用ノートを少しずつ移植している最中です。同じく Rork で個人開発を続けている方の、次の一歩のヒントになれたらと願っています。