段階公開のダイヤルを 5% から 25% へ上げる朝、私が見ている数字は毎回同じです。Crash-free users が 99.7% を割っていないか、ANR が 0.20% を超えていないか。この2つが平らなら次へ進みます。
Google Play Console でその2つを眺めていたときに、少しだけ落ち着かなくなりました。9月4日から、Android と Wear OS で Google アシスタントが Gemini へ置き換わります。切り替えは順次で、完了までに数週間かかる見込みで、そして一度切り替わった端末は元のアシスタントに戻せません 。
仮に音声からアプリを開く経路がその前後で変わったとして、その変化は Crash-free users にも ANR にも現れません。クラッシュしないからです。例外も飛びません。ただ、来なくなるだけです。
来なくなったものは、数えていなければ気づけません。個人開発で6本のアプリを運用していますが、音声から開かれた起動が全体の何%を占めるのかを、私はこれまで一度も測っていませんでした。
戻せない置き換えでは、後から対照群を作れません
プラットフォーム側の変更に対しては、ふだん「壊れてから直す」で足ります。壊れたことが観測でき、前の状態へ戻す手段があるからです。今回はその両方が成り立ちません。
置き換えは端末単位で不可逆です。切り替わった端末を元へ戻す操作は用意されていません。ルーティンやサードパーティ連携は Gemini 側の枠組みへ引き継がれるものと、移行の過程で失われるものがあると報じられていますが、どちらに転ぶかを端末ごとに事前確認する手段は、少なくとも私の手元にはありません。
一方で、Google アプリまたは端末設定から自分のタイミングで先に Gemini へ寄せることはできます。つまり「置き換え前」を観測できる期間の終わりは決まっていて、その先へ延長する手段がありません。
対象 9月4日以降の扱い
Android スマートフォン・タブレット 順次 Gemini へ置き換え
Wear OS スマートウォッチ 順次 Gemini へ置き換え
アシスタント対応ヘッドフォン 順次 Gemini へ置き換え
スマートフォン投影型の Android Auto 順次 Gemini へ置き換え
Google ビルトイン搭載車 継続
停止日をまたぐ設計そのものについては、配布済みバイナリに残った設定の話を停止日をまたいだ切り戻しの設計 に書きました。今回はサーバー側もクライアント側も自分では書き換えられない層、つまり端末の音声入力そのものが変わる場合の話です。
音声から開かれた起動を、アプリ側でどう見分けるか
まず前提を一つ。私は「音声で開かれた起動を確実に判定する方法」を持っていません。持っているのは、起動時に手元へ届く手がかりだけです。
起動時に手に入る手がかり
Activity が起動された時点で参照できるのは、おおむね次の3つです。
Intent の action と data(ACTION_VIEW によるディープリンクか、検索由来か、ランチャーからの通常起動か)
Activity.getReferrer() が返す呼び出し元のパッケージ名またはスキーム
Intent の extras に載ってくる文字列(検索クエリやディープリンクのパラメータ)
このどれが埋まるかは、呼び出す側の実装に依存します。だからこそ、いま決め打ちで1つだけを見に行く設計にすると、置き換え後に呼び出し側の作りが変わったときに何も残りません。
判定せず、そのまま記録する
私が選んだのは、判定ロジックを薄くして、素の値を丸ごと1イベントに載せる形です。分類は後から、手元のデータに対して行えます。
// MainActivity.kt — 起動の出どころをそのまま記録する
// 方針: この時点で「音声かどうか」を判定しない。判定は後段の集計側で行う。
class MainActivity : ComponentActivity () {
override fun onCreate (savedInstanceState: Bundle ?) {
super . onCreate (savedInstanceState)
recordLaunchOrigin (intent)
// 以降は通常の初期化
}
private fun recordLaunchOrigin (launchIntent: Intent ) {
// referrer は API 22 以降。android-app://<package> 形式で来ることが多い
val referrerHost = referrer?.host ?: "none"
val action = launchIntent.action ?: "none"
// 検索由来で載ることがある発話文字列。載らない経路のほうが多い
val rawQuery = launchIntent. getStringExtra (SearchManager.QUERY)
// ディープリンクのパラメータもまとめて拾う(キー名だけを残す)
val extraKeys = launchIntent.extras?. keySet ()
?. filterNot { it. startsWith ( "android." ) } // 端末側の内部キーは除外
?. sorted ()
?. joinToString ( "," )
?: ""
Firebase.analytics. logEvent ( "launch_origin" ) {
param ( "action" , action. takeLast ( 40 ))
param ( "referrer_host" , referrerHost. takeLast ( 40 ))
param ( "has_query" , if (rawQuery. isNullOrBlank ()) 0L else 1L )
param ( "extra_keys" , extraKeys. takeLast ( 90 )) // 100 文字上限に余裕を持たせる
param ( "app_version" , BuildConfig.VERSION_NAME)
}
// 発話文字列そのものは Analytics に入れず、同意済みの端末のみ自前の収集先へ送る
if (rawQuery != null && consentManager.analyticsGranted) {
utteranceCollector. enqueue (rawQuery. trim ())
}
}
}
ここでの落とし穴は2つありました。1つ目は、Firebase Analytics のイベントパラメータに文字列長の上限があることで、extras のキー名を素直に連結すると静かに切り詰められます。2つ目は、発話文字列をそのまま Analytics へ送ると、意図せず個人情報が混ざる余地が残る点です。私は発話本文を Analytics から外し、同意済み端末に限って別経路へ送る形へ分けました。
何を前提に置かないか
「音声からの起動なら referrer_host に特定のパッケージ名が入る」という前提は置いていません。置き換え後に呼び出し元のパッケージが変わる可能性がありますし、そもそも経路によっては埋まりません。判定の代わりに、action と referrer_host の組み合わせの出現分布 を残します。分布は、前提が外れても後から読み直せます。
基準線として残す項目を絞る
記録する項目が増えるほど、後から読むのが億劫になります。9月4日までに確実に運用へ載せたいので、5項目に絞りました。
項目 取得元 置き換え後に見たいこと
launch_origin の日次件数 Firebase Analytics ランチャー以外からの起動が減っていないか
action と referrer_host の組み合わせ 同上 見慣れない組み合わせが増えていないか
has_query の比率 同上 発話文字列が届く経路が細っていないか
launch_origin 経由セッションの継続時間 Firebase Analytics 入口が同じでも中身が変わっていないか
発話文字列のクラスタ分布 自前の収集先 + Gemini 言い回しの傾向が動いていないか
DAU やリテンションのような大きい数字は、ここでは主役になりません。音声起点の起動がもし全体の数%なら、その半減は DAU のグラフでは誤差に沈みます。沈む数字を、沈まない粒度で持っておく作業です。
AdMob の収益側も同じ理由で分けて見ています。入口が細っても表示回数の総量は他の入口が補ってしまうため、収益のグラフは最後まで動きません。
集めた発話を Gemini で分類して、分布の変化を見る
発話文字列は、1件ずつ読んでも意味がありません。「壁紙 変えたい」「壁紙アプリ 開いて」「浮世絵の壁紙 出して」が同じ意図なのか違う意図なのかは、件数が増えるほど手作業では判断できなくなります。
そこで週次で Gemini に分類させ、クラスタごとの件数だけを時系列で持ちます。分類の粒度が週ごとにぶれると比較にならないので、カテゴリは固定して渡します。
# weekly_utterance_profile.py — 週次で発話をクラスタ分類し、件数分布だけを残す
import os
import json
from collections import Counter
from google import genai
from pydantic import BaseModel
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
# 粒度を固定する。ここを毎週変えると前週と比較できなくなる
CATEGORIES = [
"open_app" , # アプリを開くだけ
"change_wallpaper" , # 壁紙の変更・設定
"search_theme" , # 特定のテーマや題材を指定
"control_playback" , # 再生・停止などの操作
"other" ,
]
class Labeled ( BaseModel ):
index: int
category: str
class Batch ( BaseModel ):
items: list[Labeled]
def classify (utterances: list[ str ]) -> Counter:
numbered = " \n " .join( f " { i } : { u } " for i, u in enumerate (utterances))
prompt = (
"次の発話を、指定カテゴリのいずれか1つに割り当ててください。 \n "
f "カテゴリ: { ', ' .join( CATEGORIES ) }\n "
"判断がつかない場合は other にしてください。 \n\n "
f " { numbered } "
)
# temperature / top_p / top_k は非推奨のため指定しません
res = client.models.generate_content(
model = "gemini-3.7-flash" ,
contents = prompt,
config = {
"response_mime_type" : "application/json" ,
"response_schema" : Batch,
},
)
parsed = Batch.model_validate_json(res.text)
counts = Counter()
for item in parsed.items:
# モデルが未知のカテゴリ名を返した場合の保険
counts[item.category if item.category in CATEGORIES else "other" ] += 1
return counts
def profile (week_id: str , utterances: list[ str ], batch_size: int = 80 ) -> dict :
total = Counter()
for i in range ( 0 , len (utterances), batch_size):
total += classify(utterances[i : i + batch_size])
n = sum (total.values()) or 1
return {
"week" : week_id,
"n" : n,
"share" : {c: round (total[c] / n, 4 ) for c in CATEGORIES },
}
if __name__ == "__main__" :
with open ( "utterances_2026w34.json" , encoding = "utf-8" ) as f:
rows = json.load(f)
print (json.dumps(profile( "2026-W34" , rows), ensure_ascii = False , indent = 2 ))
temperature を指定していないのは、書き忘れではありません。サンプリングパラメータは非推奨になっているため、いま新しく書くコードで明示指定を増やしたくない、という判断です。非推奨後に何が変わるかについてはtemperature 非推奨で先に困った側 に別途書きました。
出力は件数ではなく比率で持ちます。週ごとに母数が変わるので、件数のままだと利用の増減と分布の変化が混ざります。
端末を1台だけ先に切り替えるという判断
自分の検証端末をどう扱うかで、3つの選択肢がありました。
選択肢 得られるもの 失うもの
全端末で自動移行を待つ 一般ユーザーと同じ条件 置き換え後の挙動を事前に触れない
1台だけ先に手動で切り替える 置き換え前後を同時に手元で比較できる その1台の「置き換え前」を失う
全端末を先に切り替える 移行作業を一度で終えられる 比較対象が手元から消える
私は真ん中を選びました。手元で比較できる期間が短いほど、あとで届く「音声で開けなくなった」という報告を、置き換えのせいなのか自分のアプリ側の変更のせいなのか切り分けられなくなります。
この場合は、切り替える1台を主力ではない端末 にするのを推奨します。日常的に使っている端末を先に切り替えると、自分の使い方が変わったのか挙動が変わったのかが混ざります。私は普段テストにしか使っていない端末を先に寄せました。
予想と違ったのは、記録よりも解釈の側でした
作業を始めた時点では「記録さえ取れば、9月4日で線を引いて前後を比べられる」と考えていました。ここが外れました。
置き換えは9月4日に一斉に起きるのではなく、順次で、完了までに数週間かかります。つまり同じ日の同じイベントログの中に、置き換え済みの端末と未置き換えの端末が混在します。日付で before と after を切ると、after 側に大量の before が混ざり、差は薄まって見えます。差が薄いのか、混ざって薄く見えているのかを、日付だけでは区別できません。
しかも、アプリ側から「この端末はどちらのアシスタントか」を知る手段は用意されていません。端末が自己申告してくれない以上、外から与えた日付で分けるやり方は原理的に精度が出ません。
そこで、切り替えの推定を外側の日付から内側の指標へ移しました。発話の言い回しの分布が動いたかどうかを見るほうが、日付よりも実態に近いはずだと考えたからです。移行期間の途中で other の比率だけが上がるなら、それは利用の減少ではなく言い回しの変化を示している可能性が高くなります。
Gemini によるクラスタ分類を入れた本当の理由は、分析を高級にしたかったからではありません。日付で前後を切れないと分かったから です。順番が逆だったら、たぶん比率ではなく件数を眺めて満足していたと思います。
停止や置き換えの前に何を数え始めるかという話は、モデル停止のときにも同じ形で出てきました。そのときの棚卸しの手順は停止前の利用箇所の洗い出し にまとめてあります。
9月4日までに済ませること
残り日数を踏まえると、優先順位はかなりはっきりします。
launch_origin の記録を入れたビルドを、ストア審査の期間を見込んで先に出す。審査と段階公開を合わせると数日は消えます
記録が実際に届いているかを、自分の端末で音声とランチャーの両方から起動して確認する
発話文字列の収集について、同意状態の扱いとプライバシーポリシーの記述を先に整える
検証端末を1台だけ手動で Gemini へ寄せ、置き換え後の挙動を自分の目で見ておく
週次のクラスタ分布を、置き換え前に最低1回は取る。1点しかない基準線でも、0点よりは比較になります
3番だけは順番を前に出しても構いません。収集の設計が決まらないうちにビルドを出すと、あとから同意の扱いを変えるために再度リリースが必要になります。
次の一手
置き換えの前にできることは、実はそれほど多くありません。挙動を先読みして作り込むより、あとから読み直せる形で数えておく ほうが効きます。
まずは自分のアプリで、ランチャー以外から開かれた起動が日次で何件あるのかを1つだけ数えてみてください。その1本の折れ線があるかないかで、9月以降に届く報告の読み方が変わります。
私自身、今回の作業で「測っていなかった」ことに気づけたのが一番の収穫でした。同じ穴を抱えている方の参考になれば嬉しく思います。お読みいただきありがとうございました。