Firebase Remote Config の値を更新したあと、D1 リテンションが 0.8 ポイントだけ動いた——それを偶然と切り捨てるのか、文言の差として真面目に受け取るのか。個人開発で6本の壁紙アプリを並行運営していると、こういう小さな変化を計測する仕組みがあるかどうかで、次の打ち手の精度がずいぶん変わってきます。
最近、初回起動時に表示するオンボーディングの文言を Gemini 2.5 Flash で多言語生成し、Firebase Remote Config 経由で段階公開する仕組みを個人開発で整えました。これまで翻訳代行サービスに数日かけて依頼していた工程を、Apps Script のスケジューラから数十秒で回せるようにした記録です。
この記事は、私が実際に運用している Apps Script のコードと、段階公開で D1 を計測するためのイベント設計、そして6アプリ横断で見えてきた数値を、なるべく省略せずまとめたものです。動作確認は2026年5月の Gemini 2.5 Flash と Firebase Remote Config REST API v1 で行いました。
Remote Config に「文言」を寄せる設計判断
オンボーディング文言をアプリのリソースファイル(Android なら strings.xml、iOS なら Localizable.strings)に直接書いてしまうと、文言を変えるたびに新しいバイナリをストアに提出する必要があります。審査に1〜3日かかりますし、リリース後に「やはり別の言い回しのほうが良かった」と気づいても即座には戻せません。私はこの遅さに耐えられず、オンボーディングの主要な3画面分の文言は Remote Config に寄せる設計に変えました。
Remote Config に置くべきキーを決めるとき、私は以下の3つの基準で選んでいます。
ストア審査を経ずに差し替えたい文言かどうか
多言語展開する文言かどうか(英語・日本語・中国語簡体字・スペイン語・ポルトガル語・ドイツ語・フランス語の7言語)
A/B テストで差を測りたい文言かどうか
逆に、利用規約・プライバシーポリシー本文・通知許諾の説明文といった「法的に検証済み」の文言は Remote Config に置かず、リソースファイルに残しています。Remote Config に置いた文言は AI で生成しなおすたびに微妙にニュアンスが変わるので、法的責任が絡む文言までそこに置くと事故が起きます。私の場合、最初にこの線引きを明確にしたことで、後の運用がだいぶ楽になりました。
なぜ Gemini 2.5 Flash を選んだか
文言生成のモデル選定で私が比較したのは Gemini 2.5 Flash、Gemini 2.5 Pro、Gemini 3 Pro の3つでした。最終的に Gemini 2.5 Flash を採用しました。判断軸は以下の3点です。
コスト : 7言語 × 3画面 × 6アプリ = 126件の文言生成を毎週走らせる前提で、Pro 系を使うと1か月あたり ¥2,000〜¥3,000 程度になります。Flash なら同じワークロードで月 ¥300 を下回るため、長期運用の負担が圧倒的に小さくなります。
レイテンシ : 1キーあたりの生成は平均 800ms 前後。Pro 系は同条件で 2.5〜4 秒かかります。Apps Script は6分の実行時間制限があるため、126件を一気に回す前提だとレイテンシは死活問題でした。
トーンの安定性 : アプリの世界観に合わせた「やわらかい言い回し」を出させる用途では、Flash のほうがバラつきが少ない印象でした。Pro は思考過程が長い分、たまに「説明的すぎる」文言を返してきます。
「文言生成は Flash、思考が必要な分析は Pro」という使い分けは、Crashlytics 自動分析の記事でも書きましたが、今回のオンボーディング用途は完全に Flash 向きです。簡潔さと一貫性が重要で、深い推論は要らないからです。
文言生成パイプラインの全体像
具体的な構成を共有します。私が運用している Apps Script は週次でトリガーされ、以下の手順を踏みます。
Google Sheets の「マスタープロンプト」シートを読み込む(アプリごとのトーン定義・対象画面・既存文言)
各画面・各言語の組み合わせに対して Gemini 2.5 Flash を呼び出す
返ってきた JSON を検証し、長さ制限(タイトル40文字以内、本文120文字以内)を満たしているか確認
Firebase Remote Config REST API で Conditions を組み立て、段階公開用のパラメータとして登録
Slack に「今週のロールアウト候補」として diff を投稿
私が承認したら、もう一度 Apps Script を実行して Remote Config を本公開モードに更新
このフローのポイントは、AI が出力した文言をそのままユーザーに見せないことです。Slack に diff を流して私が一度目を通す工程を必ず挟んでいます。AI の出力に明らかな違和感がある場合は、その場で却下して再生成します。Helpful Content の観点でも、最終判断を人間が握る運用にしておくのは安心材料になります。
Apps Script から Gemini 2.5 Flash を呼ぶ実装
Apps Script でのコードを示します。UrlFetchApp で REST API を叩く形なので、ローカル環境でも Cloud Functions でも同じロジックがそのまま動きます。
function generateOnboardingCopy ( appId , screenId , lang , tone , currentCopy ) {
const apiKey = PropertiesService. getScriptProperties (). getProperty ( 'GEMINI_API_KEY' );
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${ apiKey }` ;
const systemInstruction = [
`あなたは個人開発者の壁紙アプリ「${ appId }」のオンボーディング文言を作成します。` ,
`トーン定義: ${ tone }` ,
`言語: ${ lang }` ,
`画面: ${ screenId }` ,
`タイトルは40文字以内、本文は120文字以内に必ず収めてください。` ,
`絵文字は使わないこと。誇張表現("最強""神""爆速"等)は禁止。` ,
`必ず JSON 形式で {"title": "...", "body": "..."} を返してください。`
]. join ( ' \n ' );
const userPrompt = [
`現在の文言:` ,
`タイトル: ${ currentCopy . title }` ,
`本文: ${ currentCopy . body }` ,
`` ,
`これを ${ lang } 向けに刷新してください。意味は保ちつつ、より自然で温かい言い回しに変えてください。`
]. join ( ' \n ' );
const payload = {
systemInstruction: { parts: [{ text: systemInstruction }] },
contents: [{ role: 'user' , parts: [{ text: userPrompt }] }],
generationConfig: {
temperature: 0.7 ,
maxOutputTokens: 512 ,
responseMimeType: 'application/json' ,
responseSchema: {
type: 'object' ,
properties: {
title: { type: 'string' },
body: { type: 'string' }
},
required: [ 'title' , 'body' ]
}
}
};
const response = UrlFetchApp. fetch (endpoint, {
method: 'post' ,
contentType: 'application/json' ,
payload: JSON . stringify (payload),
muteHttpExceptions: true
});
if (response. getResponseCode () !== 200 ) {
throw new Error ( `Gemini API error ${ response . getResponseCode () }: ${ response . getContentText () }` );
}
const data = JSON . parse (response. getContentText ());
const text = data.candidates[ 0 ].content.parts[ 0 ].text;
const parsed = JSON . parse (text);
// 長さ制約の事後検証
if (parsed.title. length > 40 || parsed.body. length > 120 ) {
throw new Error ( `Length violation: title=${ parsed . title . length }, body=${ parsed . body . length }` );
}
return parsed;
}
responseSchema を使って構造化出力を強制している点が肝です。Gemini 2.5 Flash は responseMimeType: 'application/json' だけでも JSON を返してくれることが多いのですが、responseSchema を併用すると安定度が一段上がります。スキーマ違反でパースに失敗するケースが、私の運用では 0.3% から 0.02% まで下がりました。
Remote Config REST API に流し込む実装
生成した文言を Remote Config に書き込む実装は、認証部分が少しややこしいです。Firebase Admin SDK ではなく、サービスアカウントの JWT を Apps Script 内で組み立てて Bearer トークンを取得する形にしています。Firebase Remote Config REST API の v1 仕様に従います。
function updateRemoteConfig ( appId , copyMap ) {
const accessToken = getServiceAccountAccessToken_ ();
const projectId = PropertiesService. getScriptProperties (). getProperty ( `FIREBASE_PROJECT_${ appId }` );
// 1. ETag 付きで現在の template を取得
const getResp = UrlFetchApp. fetch (
`https://firebaseremoteconfig.googleapis.com/v1/projects/${ projectId }/remoteConfig` ,
{
method: 'get' ,
headers: { Authorization: `Bearer ${ accessToken }` },
muteHttpExceptions: true
}
);
if (getResp. getResponseCode () !== 200 ) {
throw new Error ( `Remote Config get failed: ${ getResp . getContentText () }` );
}
const etag = getResp. getHeaders ()[ 'ETag' ];
const template = JSON . parse (getResp. getContentText ());
// 2. parameters を multi-locale 形式で更新
for ( const [ screenId , langMap ] of Object. entries (copyMap)) {
for ( const [ lang , copy ] of Object. entries (langMap)) {
const key = `onboarding_${ screenId }_${ lang }_title` ;
template.parameters[key] = {
defaultValue: { value: copy.title },
valueType: 'STRING' ,
description: `Auto-generated by Gemini 2.5 Flash on ${ new Date (). toISOString () }`
};
const bodyKey = `onboarding_${ screenId }_${ lang }_body` ;
template.parameters[bodyKey] = {
defaultValue: { value: copy.body },
valueType: 'STRING' ,
description: `Auto-generated by Gemini 2.5 Flash on ${ new Date (). toISOString () }`
};
}
}
// 3. 段階公開条件を組み立て(20% rollout のための %-based condition)
template.conditions = template.conditions || [];
const rolloutCondName = `onboarding_v3_rollout_20` ;
if ( ! template.conditions. find ( c => c.name === rolloutCondName)) {
template.conditions. unshift ({
name: rolloutCondName,
expression: `percent <= 20` ,
tagColor: 'BLUE'
});
}
// 4. PUT で書き戻し
const putResp = UrlFetchApp. fetch (
`https://firebaseremoteconfig.googleapis.com/v1/projects/${ projectId }/remoteConfig` ,
{
method: 'put' ,
headers: {
Authorization: `Bearer ${ accessToken }` ,
'Content-Type' : 'application/json; UTF-8' ,
'If-Match' : etag
},
payload: JSON . stringify (template),
muteHttpExceptions: true
}
);
if (putResp. getResponseCode () !== 200 ) {
throw new Error ( `Remote Config put failed: ${ putResp . getContentText () }` );
}
return JSON . parse (putResp. getContentText ());
}
ETag の管理が必須です。If-Match を付けずに PUT を送ると、別の更新と競合した場合に既存の Remote Config を上書きしてしまう可能性があります。私が一度この事故をやりかけて、深夜に5アプリの Remote Config を慌てて手動で戻したことがあります。それ以来 ETag を必ず取り回すように直しました。
段階公開(gradual rollout)で D1 を計測するイベント設計
Remote Config の段階公開機能は、ユーザーをランダムにパーセンテージで分けて値を出し分けます。20% のユーザーには新しい文言を、80% には従来文言を見せる構成にしました。
ここで重要なのは、Firebase Analytics 側で「どちらの文言を見たユーザーか」を識別できる状態にしておくことです。Remote Config から取得した値をそのままユーザープロパティに書き込みます。
// Android: Kotlin
val remoteConfig = FirebaseRemoteConfig. getInstance ()
remoteConfig. fetchAndActivate (). addOnCompleteListener { task ->
if (task.isSuccessful) {
val variant = remoteConfig. getString ( "onboarding_variant" ) // "v3" or "v2"
FirebaseAnalytics. getInstance ( this ). setUserProperty ( "onboarding_variant" , variant)
FirebaseAnalytics. getInstance ( this ). logEvent ( "onboarding_shown" ) {
param ( "variant" , variant)
param ( "screen" , "welcome" )
}
}
}
// iOS: Swift
RemoteConfig. remoteConfig (). fetchAndActivate { status, error in
guard error == nil else { return }
let variant = RemoteConfig. remoteConfig ()[ "onboarding_variant" ]. stringValue ?? "v2"
Analytics. setUserProperty (variant, forName : "onboarding_variant" )
Analytics. logEvent ( "onboarding_shown" , parameters : [
"variant" : variant,
"screen" : "welcome"
])
}
ユーザープロパティに onboarding_variant を入れておくと、後で BigQuery にエクスポートした Analytics データに対して WHERE user_properties.onboarding_variant = 'v3' で絞り込めるようになります。これがあるかないかで、D1 リテンションの差を統計的に検証できるかどうかが決まります。
D1 リテンションは私は以下の SQL で抽出しています。Firebase Analytics の BigQuery エクスポートが有効になっている前提です。
WITH first_open AS (
SELECT
user_pseudo_id,
MIN (event_date) AS first_day,
ANY_VALUE(user_properties) AS uprops
FROM `myproject.analytics_123456789.events_*`
WHERE event_name = 'first_open'
AND _TABLE_SUFFIX BETWEEN '20260510' AND '20260518'
GROUP BY user_pseudo_id
),
day1_active AS (
SELECT DISTINCT user_pseudo_id, event_date
FROM `myproject.analytics_123456789.events_*`
WHERE event_name IN ( 'app_open' , 'screen_view' )
)
SELECT
( SELECT value . string_value FROM UNNEST( fo . uprops ) WHERE key = 'onboarding_variant' ) AS variant,
COUNT ( DISTINCT fo . user_pseudo_id ) AS new_users,
COUNT ( DISTINCT CASE
WHEN d1 . event_date = FORMAT_DATE( '%Y%m%d' , DATE_ADD(PARSE_DATE( '%Y%m%d' , fo . first_day ), INTERVAL 1 DAY ))
THEN fo . user_pseudo_id END ) AS day1_retained,
SAFE_DIVIDE(
COUNT ( DISTINCT CASE
WHEN d1 . event_date = FORMAT_DATE( '%Y%m%d' , DATE_ADD(PARSE_DATE( '%Y%m%d' , fo . first_day ), INTERVAL 1 DAY ))
THEN fo . user_pseudo_id END ),
COUNT ( DISTINCT fo . user_pseudo_id )
) AS d1_retention
FROM first_open fo
LEFT JOIN day1_active d1 ON fo . user_pseudo_id = d1 . user_pseudo_id
GROUP BY variant
ORDER BY variant;
このクエリを Apps Script からスケジュール実行し、結果を Sheets に書き戻して可視化しています。週次でグラフを眺めると、文言を変えたタイミングと D1 の動きの相関がだんだん見えるようになります。
AdMob の eCPM 影響を切り分ける
オンボーディング文言を変えると、その先のリテンションが変わり、結果として AdMob の eCPM にも影響が出ます。リテンションの高いユーザーは平均セッション数が増え、広告表示回数も増え、eCPM が上がる傾向があります。
「文言を変えたら eCPM が下がった」というケースを判定するには、文言別ではなく、流入元 × 文言の組み合わせで層別する必要があります。私が運用している壁紙アプリでは、流入の 70% がオーガニック検索です。残りは ASA(Apple Search Ads)と Google Ads。それぞれの D1 リテンションと eCPM を別々に追います。
具体的には Analytics の source / medium パラメータと、Remote Config の onboarding_variant を組み合わせた pivot を Sheets で毎週眺めています。新文言 v3 が ASA 流入で D1 +1.8 ポイント・eCPM +3.2% という結果が出たので、ASA 経由のユーザーには v3 を、オーガニック検索ユーザーには v2 を出し続けるという出し分けに落ち着きました。Remote Config の condition で audience を組み合わせれば、こうした出し分けも条件分岐だけで実現できます。
6アプリ横断で見えてきた4つの落とし穴
6本の壁紙アプリでこの仕組みを並行運用してきて、私自身がつまずいたところを記録しておきます。新しく取り組む方が同じ罠に落ちないよう、共有します。
落とし穴1: Remote Config のフェッチタイミング
fetchAndActivate() を onCreate で呼び出すと、初回起動時には間に合いません。Splash 画面で1〜2秒待たせる設計にするか、setMinimumFetchIntervalInSeconds(0) を開発時のみ有効にして、本番では 3600 秒など適度な値を入れる必要があります。私の場合、本番は 3600 秒、デバッグビルドは 0 秒という分岐を入れています。
落とし穴2: 言語コードの mismatch
iOS と Android で Locale の表現が微妙に違います。iOS は zh-Hans、Android は zh-CN のような違いがあります。私は Apps Script 側で両方の言語コードを同時に書き込む形に統一しました。onboarding_welcome_zh_title と onboarding_welcome_zh-Hans_title の両方を Remote Config に入れておけば、クライアント側のコードがどちらでもヒットします。
落とし穴3: ETag 競合
複数アプリを同時に更新するスクリプトを書いたとき、ETag を取得してから PUT するまでの間に別のスクリプトが更新を入れると、HTTP 412 Precondition Failed が返ります。私は Utilities.sleep(jitter) で 0〜500ms のランダム待機を入れたうえで、失敗時は最大3回リトライする設計に直しました。
落とし穴4: A/B の母集団の偏り
percent <= 20 の段階公開条件は、ランダムサンプリングではあるものの、ユーザーIDのハッシュ値に基づいて分けられます。古いユーザーIDと新しいユーザーIDで分布の偏りが出やすく、新規ユーザーだけを見たいときには user_property 条件と組み合わせて絞り込む必要があります。私の場合、first_open_after = "2026-05-10" の条件を追加して、新規流入だけで A/B を組むようにしました。
これから取り組みたいこと
仕組みとしては安定して動いているのですが、まだ手作業が残っているところがいくつかあります。Slack に流れた diff を私が目で確認して承認する工程は、件数が増えると追いつかなくなりそうです。Gemini 2.5 Pro に「過去に却下したパターン」を学習させたうえで、自動却下の判定までやらせる仕組みを試作中です。
もう一つ、今は壁紙アプリだけで運用していますが、癒し系アプリ・引き寄せ系アプリにも横展開する予定です。トーン定義をアプリごとに変えるだけで、同じパイプラインがそのまま使えるはずです。実際にやってみると、また新しい落とし穴が見えてくると思います。見つかった気づきは、別の記事として共有していくつもりです。
オンボーディング文言は、地味に見えて D1 リテンションに直結する箇所です。1ポイントの差が、長期的にはアプリの収益に大きく効いてきます。Gemini 2.5 Flash と Firebase Remote Config の組み合わせは、個人開発者が片手間でも回せる軽さで、しかも十分な効果が出る設計だと感じています。同じ課題に取り組んでいる方の参考になれば嬉しいです。