DroidKaigi 2026 のプロポーザル採択に何が足りなかったか

一番は「ワクワクするか」。
実際のプレゼン形式のセッションを聴いてよくわかったが、ワクワクし、「これが聴きたい!」と思えるものばかりだった。

ワクワクをもう少し分解すると、以下のように感じた。

  • 自分も同様の悩みを持っていて、それを解決してくれるアイデアがあるかも知れない
  • 業界全体のトピックに対して、どういうプラクティスがあるかのキャッチアップの一助にしたい
  • 業界全体のトピックに対して、各社のプロダクトではどういう運用がされているんだろう
  • 普通に開発していたら知らない深い技術の探究

来年に向けては、これらを意識し、また Android 技術の探究を頑張る。

BasicTextField はマテリアルデザイン仕様の装飾が不要な場合に使う

テキスト入力のドキュメントを見ていた時のちょっとした気づき。

デザインにマテリアルの TextField または OutlinedTextField が必要な場合は、BasicTextField ではなく TextField をおすすめします。ただし、マテリアル仕様の装飾を必要としないデザインを構築する場合には、BasicTextField を使用する必要があります。

テキスト フィールドを構成する  |  Jetpack Compose  |  Android Developers

確かになんの装飾もないテキスト入力欄ができた。

TextField の見え方の違い

@Preview
@Composable
fun TextFieldCatalog() {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(color = MaterialTheme.colors.background)
            .padding(horizontal = 16.dp, vertical = 56.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        BasicTextField(
            value = "Basic Text Field",
            onValueChange = {},
            modifier = Modifier.fillMaxWidth()
        )
        TextField(
            value = "Text Field",
            onValueChange = {},
            modifier = Modifier.fillMaxWidth()
        )
        OutlinedTextField(
            value = "Outlined Text Field",
            onValueChange = {},
            modifier = Modifier.fillMaxWidth()
        )
    }
}

GenAI Prompt API 利用時の電力消費量の調査

背景/概要

前回の記事でGenAI Prompt API を利用してみました。オンデバイス用の軽量モデルと言えど、具体性や多様性がある返答ができており、それなりの計算リソースを使っている印象がありました。

takahana.hatenablog.com

モバイル端末においては電力消費量が気になるので、androidx.macrobenchmarkPowerMetricを使って計測をしてみます。

実行環境

計測結果

ベンチマーク結果のenergyComponentTpuUwsをみます。
Tpu: TPU (Tensor Processing Unit、Google 製 AI 処理チップ)
Uws: μWs (マイクロワット秒)。以下の表では Pixel 9 の電池容量と単位を揃えるため mWh に変換して記載しています(1 mWh = 3,600,000 μWs)。

プロンプト 平均プロンプト長(文字) 特徴 最小値 (mWh) 中央値 (mWh) 最大値 (mWh)
prompt_J 330 フレンドリー+偏り分析(簡潔版) 43.03 43.78 44.53
prompt_K 358 J + 休息提案の指示 / 例示はご褒美系の短文 46.57 46.99 47.42
prompt_L 360 J + 休息提案の指示 / 例示は J のまま(長め) 48.14 48.36 48.61

※ 実行は 2 イテレーション、1 イテレーションにつき 10 テストケースを逐次実行した際の合計値です。1 回の推論あたりに換算すると 約 2.3 mWh(J: 2.19、K: 2.35、L: 2.42)が TPU で消費されています。

どのくらいのエネルギー?

Pixel 9 の電池容量で換算してみます。

※ Google 公式ストアには 標準 4,700 mAh(最小 4,558 mAh) までしか記載がなく、公称電圧や Wh は公開されていません。本記事では iFixit の純正バッテリー商品ページに掲載されているセルラベル値(最小 4,558 mAh, 3.9V, 17.77 Wh)を換算基準として採用しました。

energyTotal(CPU・TPU・メモリ・画面など端末全体の合計)ベースで見ると、prompt_K の 1 回の推論(≒ 26 秒)で 約 8.25 mWh = 電池 0.046% を消費しています。推論中の平均消費電力は 約 1.14 W でした。

条件 電池消費
prompt_K を 1 回実行 約 0.046%(約 8.25 mWh)
prompt_K を 100 回実行 約 4.6%(約 0.83 Wh)
prompt_K を 1,000 回実行 約 46%(約 8.25 Wh)
prompt_K を 1 時間連続で実行(約 138 回) 6.4% / h(約 1.14 Wh/h)

1 時間あたりの電池消費の目安(他のレビューサイトで公開されている Pixel 9 / 9 Pro の実機検証と比較):

操作 消費 (%/h) 機種・条件 出典
prompt_K 連続実行 約 6.4 Pixel 9 / 10 ケース × 連続推論 本記事の計測値
YouTube 動画視聴 約 5.9 Pixel 9 / 連続再生 16h52m から逆算 1
YouTube 動画視聴(1080P 60fps) 約 7 Pixel 9 Pro / 1 時間で 7% 減 2
Amazon Music 再生 約 3 Pixel 9 Pro 3
ゲーム(原神 デフォルト画質) 約 17 Pixel 9 Pro 4
画面オフで放置 約 0.46 Pixel 9 Pro / 24 時間で 11% 5

連続でプロンプトを叩いたときの電池消費は、YouTube 動画視聴とほぼ同じレンジで、音楽再生より重く、ゲームよりはずっと軽いあたりに位置します。

8.25 mWh を身近な目安に直すと、9W の LED 電球を約 3.3 秒点灯するのと同じエネルギー量になります。

考察

プロンプト長と TPU 消費の関係

プロンプト 1 文字あたりの TPU 消費を出すと 0.131〜0.134 mWh/文字 で揃っており、入力プロンプトの長さにほぼ比例しているように見えます。

プロンプト 長さ(文字) TPU 中央値 (mWh) mWh / 文字
J 330 43.78 0.1327
K 358 46.99 0.1313
L 360 48.36 0.1343

ペアで比較してみます:

比較 長さ差 TPU 差
J → K +28 文字(+8.5%) +7.3%
J → L +30 文字(+9.1%) +10.5%
K → L +2 文字(+0.6%) +2.9%

J → K は長さ比とほぼ一致(+8.5% vs +7.3%)していて、比例と言って差し支えなさそうです。一方で K → L は入力長が 2 文字しか違わないのに TPU が 2.9% 増えている ので、入力長だけでは説明できない差もあります。

K と L はプロンプト全体のうち 回答例の 1 行だけが違う 構成で、中身は以下の通りです。

  • K の例示
    {"message": "コツコツ続けていて偉いね!", "action": "今週末、友達とカフェでおしゃべりする時間を作る", "reason": "頑張っている自分へのご褒美に、リフレッシュの時間も大切にしてね"}
  • L の例示
    {"message": "ランニング頑張ってるね!", "action": "走った後に5分だけ振り返りを書いてみる", "reason": "運動は充実しているので、学びの要素を少し加えるとバランスが良くなりそうです"}

例示の内容が何らか影響している可能性はありますが、今回のデータだけでは断定できません。入力長と併せて出力長なども見ていけば、もう少し踏み込んだ分析ができそうです。

検証:GenAI を使わないと TPU はどうなるか

TPU の消費が本当に Gemini Nano の推論由来なのかを確認するため、推論部分だけをスキップして(バッチ処理のループや UI 更新、ファイル書き込みはそのまま実行)、同じ prompt_K を走らせました。

結果:

指標 通常(prompt_K) 推論なし(prompt_K)
energyComponentTpuUws (中央値) 46.99 mWh 0.000 mWh
energyTotalUws (中央値) 164.91 mWh 9.50 mWh
totalRunTimeNs 522 s 79.7 s

期待通り TPU は完全に 0 になり、先ほどの energyComponentTpuUws は Gemini Nano の推論処理による消費であることが確認できました。また、全体のエネルギーも 94% 減、実行時間も 85% 減となっており、このバッチ処理のワークロードは大半が推論時間であることも併せて分かります。

まとめ

  • Pixel 9 上の Gemini Nano v2 で、1 回のプロンプト実行あたり 約 8.25 mWh = 電池 0.046% を消費する(energyTotal ベース)
  • 1 時間連続で回し続けると電池を 約 6.4% / h 消費。YouTube 動画視聴と同等レンジで、音楽再生より重く、ゲームよりはずっと軽い
  • プロンプトの微調整(30 文字程度の差+指示 1 文の追加)でも TPU 消費は 約 10% 変動 したが、1 日数十回の用途では電池影響はほぼ誤差。万単位のバッチ処理に投入するなら効いてくる
  • 推論を外すと TPU は 0 mWh になり、想定通り energyComponentTpuUws は推論由来の消費として扱える

注意点

  • イテレーションは 2 回しか回しておらず、統計的な幅は狭いです。プロンプト間の比較を精密に行いたい場合はイテレーション数を増やす必要があります。

GenAI Prompt API で利用するプロンプトの品質を評価する

背景

ML Kit GenAI API の機能

Gemini Nano というモバイル向けに最適化されたAIモデルがあります。 Gemini Nano は Android OS 上の AI Core システムサービスから提供され、Androidアプリ開発者は ML Kit SDK の Gen AI API を介して利用可能です。

android-developers.googleblog.com

ML Kit GenAI API は次の機能がサポートされています。

  • 要約
  • 校正
  • 書き換え
  • 画像の説明
  • 音声認識
  • プロンプト

Androidアプリ開発者は用途に合わせてこれらを選択できます。

developers.google.com

プロンプト機能

ML Kit GenAI API の中で唯一カスタムプロンプトを与えられるのが、プロンプト機能(GenAI Prompt API)です。 これにより様々なユースケースで利用可能になります。

ちなみに、その他の機能は要約などに特化したものなので、プロンプトによる指示は無視されたり、ランダム性が低かったりします。 一方で、出力が早いメリットなどもあります。 (AI Core のサービス内の LoRA の仕組みによって最適化されたモデルを利用しているのだと思われる)

GenAI Prompt API で利用するプロンプトの品質を評価する

入力するプロンプトは重要で、それ次第で出力結果にかなり影響します。

GenAI Prompt API を利用する上でも、一般的なプロンプトエンジニアリングのテクニックの実践は必要です。 (制約・コンテキスト・ロールを与える、例を1~3個与えるなど)

また軽量なモデルなので、普段 Gemini アプリでチャットしている感覚で簡単なプロンプトにすると、うまく期待した出力になりません。

そこで、期待する品質になるかを評価するワークフローが紹介されています。

developers.google.com

この記事でやること

この記事では、GenAI Prompt API を利用するプロンプトの品質を評価するワークフローの実践例を紹介します。

サンプルコードは以下のリポジトリで公開しています。

github.com

評価の設計

ユースケース

活動記録(勉強や運動などの)アプリを作っているとします。 そのアプリに、ユーザーの活動記録から「次にやるといい行動」を提案する機能を作ることにします。

プロンプトの入力は、ユーザーの活動記録(例:ランニング3時間、英語の勉強5時間など)になります。

出力はアプリで利用するため、以下のJSON形式で統一します。

{
  "message": "ユーザーへの励まし",
  "action": "次にやるといい行動",
  "reason": "その理由"
}

評価指標

以下の4指標でOK/NGの2値評価を行いました。

指標 OK の基準 NG の例
Format 有効なJSONで、必要なキーが存在する JSONが崩れている、キーが欠けている
Relevance(妥当性) 入力の活動内容に基づいた提案になっている 入力と無関係な提案
Actionability(実行可能性) 具体的で、今すぐ実行できる 「頑張る」のような抽象的な提案
Safety(安全性) 無理・危険のない提案 高負荷な活動の後に「もっと運動する」

成功条件: 4指標すべてがOKであれば成功とします。

Formatは自動判定、残り3指標は手動評価です。

テストケース

以下のカテゴリに分けて、合わせて10件を作成しました。

カテゴリ 件数
通常(運動・勉強) 3 ランニング3h、英語の勉強5.5h
高負荷(やりすぎ) 2 勉強12h、筋トレ8h+ランニング5h
軽量(始めたばかり) 2 ヨガ30分1回、プログラミング1h
複合(複数活動) 2 ランニング+読書+瞑想
境界(ごくわずか) 1 散歩15分1回

ワークフロー

全体の流れ

ローカル(Gemma 3n / mlx-lm)で高速に試行
        ↓
成功率と出力品質でプロンプト候補を絞る
        ↓
Android実機(Prompt API)で最終評価
        ↓
ローカルと実機の結果を比較

ローカル評価には Apple Silicon Mac 上で動作する mlx-lm を使い、mlx-community/gemma-3n-E4B-it-lm-4bit を実行しました。

評価ツール

ローカル評価、手動判定用のブラウザビューア、実機評価(adb連携)、結果比較のスクリプトをPythonで構築しました。 実機テスト用のCompose Multiplatformアプリも合わせて、サンプルリポジトリで公開しています。

プロンプト改善の流れ

10回の改善ループ(Prompt A→L)を行いました。 わかりやすく出力に影響が出た例を主に紹介します。

Step 1: 最小指示から始める(Prompt A → 成功率 40%)

プロンプト:

この記録をもとに、ユーザーが次にやるべき行動を1つ提案してください。
以下のJSON形式のみで回答してください:
{"action": "次にやる行動", "reason": "その理由"}

- ランニング: 3時間0分(6回)

出力:

{"action": "次にやる行動", "reason": "その理由"}   ← プレースホルダーがそのまま出力

{"action": "目標設定", "reason": "現在の記録を分析し、次のステップとして具体的な目標を設定することで..."}

プレースホルダーがそのまま出力され、本来の回答は2番目のJSONに。パーサは最初のJSONを拾ってしまうため、10件中6件が失敗しました。

洞察: 小型モデルにプレースホルダー付きJSON例を見せると、そのままコピーする

Step 2: 制約を増やす(Prompt B → 成功率 0%)

「安全な提案をすること」「1つだけ提案すること」などルールを箇条書きで追加しました。

プロンプト(入力: 読書 2時間 4回):

あなたはユーザーの活動を支援するアシスタントです。
以下はユーザーの今月の活動記録サマリーです。

- 読書: 2時間0分(4回)

ルール:
- 無理のない、安全な提案をすること
- 具体的で今すぐ実行できる行動を1つだけ提案すること
- 活動記録の傾向(頻度・時間)を考慮すること

以下のJSON形式のみで回答してください:
{"action": "次にやる行動", "reason": "その理由"}

出力:

{"action": "次にやる行動", "reason": "その理由"}

{"action": "読書を続ける", "reason": "読書は4回も行っているため..."}
{"action": "軽いストレッチをする", "reason": "読書は集中力を必要とするため..."}
{"action": "水分補給をする", "reason": "読書中は水分補給が大切です..."}
{"action": "休息する", "reason": "読書は集中力を必要とするため..."}
{"action": "次の読書時間を見つける", "reason": "読書を4回も行っているため..."}
{"action": "読書記録を続ける", "reason": "読   ← トークン上限で途切れ

オウム返しに加えて、「1つだけ」の指示を無視して5〜6個の提案を暴走出力しています。全滅でした。

洞察: 文章の制約を増やしても小型モデルは指示追従が弱く、逆効果になることがある

Step 3: 例示を与える(Prompt C → 成功率 100%)

良い例2つ・悪い例2つを提示しました。

プロンプト(入力: ランニング 3時間 6回):

あなたはユーザーの活動を支援するアシスタントです。
以下はユーザーの今月の活動記録サマリーです。

- ランニング: 3時間0分(6回)

次にやるべき行動を1つ提案してください。

良い提案の例:
{"action": "15分だけ軽いストレッチをする", "reason": "運動後の疲労回復に効果的です"}
{"action": "今日の学習内容を5分で振り返る", "reason": "短い復習が記憶の定着に役立ちます"}

悪い提案の例:
{"action": "頑張る", "reason": "応援しています"}
{"action": "もっと勉強する", "reason": "足りないから"}

ルール:
- 無理・危険な提案はしない
- 入力の活動内容に基づいた具体的な提案をする
- 以下のJSON形式のみで回答する

{"action": "次にやる行動", "reason": "その理由"}

出力:

{"action": "15分だけ軽いストレッチをする", "reason": "ランニング後の筋肉の疲労回復を促し、怪我の予防につながります。"}

Format成功率は100%に跳ね上がりました。きれいなJSON1つだけが返ってきます。しかし出力を見ると、10件中8件が例示のほぼコピーでした。

出力されたaction 出現回数
15分だけ軽いストレッチをする 4回
今日の学習内容を5分で振り返る 4回
その他(オリジナル) 2回

入力がランニングでも読書でもヨガでも、同じ2つの回答が使い回されています。

洞察: 例示はフォーマット安定性に劇的な効果があるが、少ない例示は出力の多様性を奪う

Step 4: 「コピーせず」の明示指示(Prompt F → 成功率 100%、コピー0件)

例示を1つに減らし、「上の例をコピーせず」と明示しました。

プロンプト(入力: ランニング 3時間 6回):

あなたはユーザーの活動を支援するアシスタントです。
以下はユーザーの今月の活動記録サマリーです。

- ランニング: 3時間0分(6回)

ユーザーの活動内容と時間に合った、具体的な次の行動を1つだけ提案してください。
無理のない安全な提案をしてください。

回答例:
{"action": "ランニング後に10分ストレッチする", "reason": "筋肉の回復を促します"}

上の例をコピーせず、入力に合った提案をJSON形式のみで回答:

出力:

{"action": "ウォーキングで体を休める", "reason": "ランニング後の疲労回復を促し、怪我のリスクを減らします"}

別の入力(プログラミング 1時間 2回)での出力:

{"action": "プログラミングの学習教材を1つ選択して、15分だけ取り組む", "reason": "少しずつ学習習慣を維持し、モチベーションを保つため"}

例示コピーが完全に解消され、10件すべてが入力に合わせたオリジナルの提案になりました。

洞察: 「コピーせず」のメタ指示は小型モデルでも理解できる。これが最も効果的な改善だった

ただし、ここで重要な発見がありました。例示を「友達とカフェでおしゃべり」のような入力と無関係な汎用的内容にしたところ、逆に10/10が完全コピーに。

入力が - 英語の勉強: 5時間30分(10回) でも:

{"message": "コツコツ続けていて偉いね!", "action": "今週末、友達とカフェでおしゃべりする時間を作る", "reason": "頑張っている自分へのご褒美に、リフレッシュの時間も大切にしてね"}

英語の勉強という入力なのに、例示がそのまま返ってきています。例示のactionに入力の活動名(例:ランニング)が含まれていることが、モデルに「入力に合わせてカスタマイズしなければ」と判断させるトリガーになっていたのです。

洞察: 例示は入力に紐づく具体的な内容にする必要がある。汎用的な例示は逆効果

Step 5: フレンドリーさと文脈の追加(Prompt J/L → 最終採用)

最終プロンプトでは以下を追加しました。

  • 「記録外にも様々な活動をしている」前提 — 活動が偏っているという決めつけを防ぐ
  • 高負荷時の休息提案 — 運動や勉強が多い場合にリフレッシュを促す
  • messageフィールド — ユーザーへの励ましを含める
あなたはユーザーの活動を応援するフレンドリーなアシスタントです。

以下はユーザーが「集中して取り組みたいこと」として記録した活動です。
記録外にも仕事や休息など様々な活動をしています。決めつけず、温かく応援してください。
活動量が多い場合は、休息やリフレッシュも提案してください。

{{activity_summary}}

記録の傾向を見て、ユーザーを励まし、次にやるといい行動を1つ提案してください。

回答例:
{"message": "ランニング頑張ってるね!", "action": "走った後に5分だけ振り返りを書いてみる", "reason": "運動は充実しているので、学びの要素を少し加えるとバランスが良くなりそうです"}

上の例をコピーせず、入力に合った提案をJSON形式のみで回答:

プロンプト(入力: 筋トレ8時間 + ランニング5時間):

あなたはユーザーの活動を応援するフレンドリーなアシスタントです。

以下はユーザーが「集中して取り組みたいこと」として記録した活動です。
記録外にも仕事や休息など様々な活動をしています。決めつけず、温かく応援してください。
活動量が多い場合は、休息やリフレッシュも提案してください。

- 筋トレ: 8時間0分(15回)
- ランニング: 5時間0分(10回)

記録の傾向を見て、ユーザーを励まし、次にやるといい行動を1つ提案してください。

回答例:
{"message": "ランニング頑張ってるね!", "action": "走った後に5分だけ振り返りを書いてみる", "reason": "運動は充実しているので、学びの要素を少し加えるとバランスが良くなりそうです"}

上の例をコピーせず、入力に合った提案をJSON形式のみで回答:

出力:

{"message": "筋トレとランニング、素晴らしいですね!", "action": "今日の運動で少し疲れたと思うので、温かいお風呂にゆっくり浸かってリラックスするのはどうでしょう?", "reason": "運動後のリカバリーは重要です。心身ともにリフレッシュすることで、次の活動への準備ができます。"}

高負荷なケースに対して休息を提案できるようになりました。

ローカル評価結果: 成功率90%(1件はJSON構文エラー)。

なお、プロンプトに一文追加しただけで成功率が100%→90%に下がるケースも確認しており、小型モデルではプロンプトの長さに対する感度が非常に高いことがわかりました。

小型オンデバイスモデル向けプロンプト設計のポイント

改善ループを通じて得られたポイントです。

  1. 短いプロンプトが安定する — 一文追加するだけでFormat NG率が上がることがある
  2. 例示は入力に紐づく具体的な内容にする — 汎用的な例はそのままコピーされる
  3. 「コピーせず」の明示指示が効く — 小型モデルでもこのメタ指示は理解する
  4. プレースホルダーは全て避ける{"action": "次にやる行動"} はそのまま出力される
  5. 例示は1つで十分 — 多いとコピーリスクが増し、プロンプト長も増える

実機検証の結果

ローカル vs 実機の比較

最終プロンプト(Prompt L)でローカルと実機を比較しました。

case 入力 Local (gemma-3n-E4B) Device (Prompt API)
001 ランニング 3h 気づきをメモしてみましょう 週末は少しゆっくり休んで筋肉を休ませてあげましょう
002 英語の勉強 5.5h 音楽を1曲聴いてリフレッシュする 好きな英語の映画を観てリラックスする時間を設けてみよう
004 勉強 12h 音楽を聴いてリフレッシュする ストレッチや深呼吸を5分程度取り入れてみましょう
010 散歩 15min ストレッチや短い散歩はいかがでしょうか? 明日は少し長めの散歩をしてみましょう。新しい発見があるかもしれません

実機の出力品質はローカルより高かったです。

  • 具体性: ローカルは「素晴らしいですね!」のワンパターン。実機は「6回も3時間も走るなんて」と数値に言及
  • 多様性: ローカルは「音楽を聴く」が頻出。実機は「英語の映画を観る」「筋肉を休ませる」など多彩
  • 口調: ローカルは説明的。実機はフレンドリーな語りかけ調

この品質差は、Prompt APIの裏側で適用されているLoRAアダプタの効果だと考えられます。ローカル評価のGemma 3nは素のモデルですが、Prompt APIではタスクに最適化されたLoRAが自動で適用されます。

まとめ

GenAI Prompt APIのプロンプト品質を評価するワークフローを実践しました。

  • ローカル評価で高速にイテレーション: mlx-lm + Gemma 3nで10回の改善ループを実施し、成功率を40%→90%に改善
  • 小型モデル向けの知見: 短いプロンプト、具体的な例示、「コピーせず」の明示指示が効果的
  • 実機で最終検証: ローカルと実機では出力品質に差があり、最終判断は実機で行うべき
  • LoRAの恩恵: Prompt APIは素のモデルより高品質な出力を返す。開発者はプロンプトを磨くだけでこの恩恵を受けられる

ローカル評価は実機の完全な代替にはなりませんが、プロンプトの傾向を掴み、改善の方向性を決めるには十分有効でした。

GenAI Summarization API の処理速度は6秒くらい

背景 / 概要

AndroidデバイスのオンデバイスAIである Gemini Nano というのがあります。
Gemini Nano にアクセスしてアプリに組み込むためには、Google AI Edge SDKを利用していましたが、非推奨になっています。
代わりに ML Kit Prompt API が登場しています。

developers.google.com

ML Kit Prompt API はデベロッパーとGemini Nano の間に入り、特定のユースケースに適したAPIを提供してくれます。

Google AI Edge SDKだったときは出力を得るまでに30秒くらいかかっていましたが、ML Kit Prompt API になったらどうなるのか、とても気になります。 今回は GenAI Summarization API (要約)を試していきます。

処理速度計測

計測環境

  • 実行デバイス:Pixel 9 (Android 16)
  • 設定したオプション
val summarizerOptions = SummarizerOptions.builder(context)
  .setInputType(InputType.ARTICLE)
  .setOutputType(OutputType.ONE_BULLET)
  .setLanguage(Language.JAPANESE)
  .build()

物語ぽいテキストの場合

入力:

朝霧の港町では、毎日同じ時刻に鐘が鳴る。誰が鳴らしているのかは知られていない。ただ、音が響くと漁師たちは網を畳み、子どもたちは遊びをやめ、町は一瞬だけ息をそろえる。私はその町に、逃げるように流れ着いた旅人だった。
宿の女将は、古い地図を広げて「鐘の音に逆らって歩くと、失くしたものに会える」と教えてくれた。半信半疑のまま、鐘が鳴る方向とは反対へ足を向ける。霧の中、道は細くなり、やがて灯台へ続く坂に出た。途中、拾われなかった手紙や壊れた羅針盤が、道端に静かに置かれている。誰かが諦めた証のようで、胸が少し痛んだ。

灯台の扉は開いていた。中には、白髪の番人がいて、鐘の綱を握っている。「音は境目を作るために鳴らす」と彼は言う。「向こうとこちら、過去と今。だが、越える勇気は鳴らす者ではなく、歩く者にある」。私は頷き、ポケットから折れた鍵を差し出した。長い間、開けられなかった心の扉の鍵だ。

※ChatGPTで生成したもの

出力:6995ms

* 朝霧の港町で、鐘の音に逆らって歩くと失くしたものに会えるという女将の言葉に従い、灯台へ行き、鍵を受け取り、鐘を鳴らすことで、過去と向き合い、未来への希望を見出す。

スポーツニュースぽいテキストの場合

入力:

週末に行われた各地のスポーツイベントは、シーズンの行方を占う重要な一戦が多く、連日大きな注目を集めた。プロ野球では首位チームが敵地で苦しい試合運びを強いられながらも、終盤の集中力で逆転勝利を収め、改めて総合力の高さを示した。先発投手は立ち上がりに制球を乱したものの、中盤以降は修正し、試合を作った点が評価されている。打線では主軸に加え、下位打線からのチャンス拡大が得点につながり、チーム全体の噛み合いが目立った。
一方、追いかける立場のチームは若手中心の布陣で臨み、積極的な走塁や守備で存在感を発揮したものの、あと一歩及ばなかった。監督は試合後、「結果は悔しいが、内容には手応えがある。経験を積ませながら後半戦につなげたい」と前向きな姿勢を示した。
サッカーJリーグでも上位対決が行われ、終盤に決まったセットプレーからのゴールが勝敗を分けた。選手たちは連戦の疲労を感じさせない運動量を見せ、スタンドを埋めたサポーターを沸かせた。専門家は「これからの時期は戦術だけでなく、選手層とコンディション管理が結果を左右する」と指摘しており、各クラブの総合的なマネジメント力が問われる局面に入っている。

※ChatGPTで生成したもの

出力:6781ms

* プロ野球では首位チームが逆転勝利し、総合力の高さを示した一方、追いかけるチームは若手中心の布陣で健闘した。サッカーJリーグでは上位対決が行われ、セットプレーからのゴールが勝敗を分けた。

Jsonデータの場合

入力:

猫の活動記録

{
  "schema_version": "1.0",
  "cat": {
    "cat_id": "cat-001",
    "name": "ミケ",
    "sex": "female",
    "birthday": "2022-04-15",
    "weight_kg": 4.2,
    "neutered": true,
    "notes": "食べムラあり。朝は食欲が落ちがち。"
  },
  "household": {
    "timezone": "Asia/Tokyo",
    "owner": "山田太郎"
  },
  "day": {
    "date": "2026-02-08",
    "summary": {
      "food_total_kcal_est": 235,
      "water_ml_est": 120,
      "sleep_total_min": 780,
      "play_total_min": 35,
      "litter_urine_count": 3,
      "litter_stool_count": 1,
      "vomit_count": 0
    },
    "events": [
      {
        "event_id": "evt-0001",
        "timestamp": "2026-02-08T07:15:00+09:00",
        "type": "sleep",
        "data": { "state": "wake_up" },
        "note": "起床。伸びをしてから窓際へ。"
      },
      {
        "event_id": "evt-0002",
        "timestamp": "2026-02-08T07:25:00+09:00",
        "type": "food",
        "data": {
          "meal": "breakfast",
          "food_name": "ドライフード",
          "amount_g": 18,
          "kcal_est": 70,
          "appetite": "normal"
        }
      },
      {
        "event_id": "evt-0003",
        "timestamp": "2026-02-08T07:40:00+09:00",
        "type": "water",
        "data": { "amount_ml_est": 20, "method": "bowl" }
      },
      {
        "event_id": "evt-0004",
        "timestamp": "2026-02-08T08:05:00+09:00",
        "type": "litter",
        "data": { "urine": true, "stool": false, "notes": "量ふつう" }
      },
      {
        "event_id": "evt-0005",
        "timestamp": "2026-02-08T10:30:00+09:00",
        "type": "play",
        "data": { "toy": "猫じゃらし", "duration_min": 15, "intensity": "medium" },
        "note": "最後は自分でおもちゃをくわえて持ってきた。"
      },
      {
        "event_id": "evt-0006",
        "timestamp": "2026-02-08T12:10:00+09:00",
        "type": "sleep",
        "data": { "state": "nap_start", "location": "ソファ" }
      },
      {
        "event_id": "evt-0007",
        "timestamp": "2026-02-08T15:25:00+09:00",
        "type": "sleep",
        "data": { "state": "nap_end" }
      },
      {
        "event_id": "evt-0008",
        "timestamp": "2026-02-08T18:05:00+09:00",
        "type": "food",
        "data": {
          "meal": "dinner",
          "food_name": "ウェットフード",
          "amount_g": 65,
          "kcal_est": 120,
          "appetite": "good"
        }
      },
      {
        "event_id": "evt-0009",
        "timestamp": "2026-02-08T18:25:00+09:00",
        "type": "litter",
        "data": { "urine": false, "stool": true, "stool_quality": "normal" }
      },
      {
        "event_id": "evt-0010",
        "timestamp": "2026-02-08T21:10:00+09:00",
        "type": "health_check",
        "data": {
          "eyes": "clear",
          "nose": "normal",
          "coat": "good",
          "energy": "normal",
          "pain_signs": "none"
        },
        "note": "ブラッシング嫌がらず。"
      }
    ]
  }
}

※ChatGPTで生成したもの

出力:6096ms

* 2026年2月8日のミケの活動記録:起床、食事、トイレ、遊び、昼寝、夕食、トイレ、健康診断など。

プロンプト + Jsonデータの場合

入力:

プロンプト + 猫の活動記録

これは猫の活動記録です。
このデータから、猫の性格を予測してください。

{
  "schema_version": "1.0",
  "cat": {
    "cat_id": "cat-001",
    "name": "ミケ",
    "sex": "female",
    "birthday": "2022-04-15",
    "weight_kg": 4.2,
    "neutered": true,
    "notes": "食べムラあり。朝は食欲が落ちがち。"
  },
  "household": {
    "timezone": "Asia/Tokyo",
    "owner": "山田太郎"
  },
  "day": {
    "date": "2026-02-08",
    "summary": {
      "food_total_kcal_est": 235,
      "water_ml_est": 120,
      "sleep_total_min": 780,
      "play_total_min": 35,
      "litter_urine_count": 3,
      "litter_stool_count": 1,
      "vomit_count": 0
    },
    "events": [
      {
        "event_id": "evt-0001",
        "timestamp": "2026-02-08T07:15:00+09:00",
        "type": "sleep",
        "data": { "state": "wake_up" },
        "note": "起床。伸びをしてから窓際へ。"
      },
      {
        "event_id": "evt-0002",
        "timestamp": "2026-02-08T07:25:00+09:00",
        "type": "food",
        "data": {
          "meal": "breakfast",
          "food_name": "ドライフード",
          "amount_g": 18,
          "kcal_est": 70,
          "appetite": "normal"
        }
      },
      {
        "event_id": "evt-0003",
        "timestamp": "2026-02-08T07:40:00+09:00",
        "type": "water",
        "data": { "amount_ml_est": 20, "method": "bowl" }
      },
      {
        "event_id": "evt-0004",
        "timestamp": "2026-02-08T08:05:00+09:00",
        "type": "litter",
        "data": { "urine": true, "stool": false, "notes": "量ふつう" }
      },
      {
        "event_id": "evt-0005",
        "timestamp": "2026-02-08T10:30:00+09:00",
        "type": "play",
        "data": { "toy": "猫じゃらし", "duration_min": 15, "intensity": "medium" },
        "note": "最後は自分でおもちゃをくわえて持ってきた。"
      },
      {
        "event_id": "evt-0006",
        "timestamp": "2026-02-08T12:10:00+09:00",
        "type": "sleep",
        "data": { "state": "nap_start", "location": "ソファ" }
      },
      {
        "event_id": "evt-0007",
        "timestamp": "2026-02-08T15:25:00+09:00",
        "type": "sleep",
        "data": { "state": "nap_end" }
      },
      {
        "event_id": "evt-0008",
        "timestamp": "2026-02-08T18:05:00+09:00",
        "type": "food",
        "data": {
          "meal": "dinner",
          "food_name": "ウェットフード",
          "amount_g": 65,
          "kcal_est": 120,
          "appetite": "good"
        }
      },
      {
        "event_id": "evt-0009",
        "timestamp": "2026-02-08T18:25:00+09:00",
        "type": "litter",
        "data": { "urine": false, "stool": true, "stool_quality": "normal" }
      },
      {
        "event_id": "evt-0010",
        "timestamp": "2026-02-08T21:10:00+09:00",
        "type": "health_check",
        "data": {
          "eyes": "clear",
          "nose": "normal",
          "coat": "good",
          "energy": "normal",
          "pain_signs": "none"
        },
        "note": "ブラッシング嫌がらず。"
      }
    ]
  }
}

※ChatGPTで生成したもの

出力:5989ms

* 2026年2月8日、ミケ(メス猫)の活動記録:起床、食事、トイレ、遊び、睡眠など。

わかったこと

  • 要約の生成には6~7秒かかる
  • 入力に対して出力文字数はかなり減るが、ちゃんと文脈が通ったものになる
  • 生成のランダム性は低そう
  • 要約のためのAPIなので、指示のテキストは効果がない

rememberSaveableで保存できる型の制約がAndroidだけにある

背景/概要

Compose Multiplatform を使って、Web向けのUIを作っていました。
Web向けなので Kotlin/Wasm でのビルドばかりしていましたが、Android向けにもビルドしたくなりました。
ビルドこそすんなりできましたが、rememberSaveableの箇所でランタイムでエラーになり、アプリがクラッシュしてしまいました。
Webでの動作時は問題なかったので、Android向けとは挙動が異なるようです。

今回は、その挙動の違いがコード上のどこでわかるのかを調査しました。
(技術的に何が要因で違っているかの調査まではしません)

利用バージョン

  • Kotlin: 2.2.0
  • Compose Multiplatform: 1.9.3

クラッシュ時のエラー

このようなComposeのコードを書いていました。
Sampleクラスは何でもないクラスです。

package com.example.home.ui

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
) {
    val sample = rememberSaveable { Sample() }
}
package com.example

class Sample

そして、Androidアプリのクラッシュ時には以下のようなメッセージが出ていました。

FATAL EXCEPTION: main
Process:  com.example, PID: 28769
java.lang.IllegalArgumentException: com.example.Sample@b1b76df cannot be saved using the current SaveableStateRegistry. The default implementation only supports types which can be stored inside the Bundle. Please consider implementing a custom Saver for this class and pass it to rememberSaveable().

日本語訳すると以下のようになります。

java.lang.IllegalArgumentException:
com.example.Sample@b1b76df は、現在の SaveableStateRegistry を使って保存することができません。
デフォルトの実装では、Bundle の中に保存できる型のみがサポートされています。
このクラス用に カスタム Saver を実装し、それを rememberSaveable() に渡すことを検討してください。

確かにAndroidアプリ開発においては、画面内のデータをBundleに詰めて保存することが多いので、この説明には納得です。
つまりは画面内のデータの保存がAndroid向けかWeb向けかで制約が違うのかも知れません。

rememberSaveableの役割

Android Developers の説明を参考にすると、rememberSaveableは通常のrememberで状態を保持するのとは異なり、画面回転などのConfiguration Changeがあってもデータを保持するのが役割です。

また、Bundleに保存可能なすべての値を自動的に保存するという説明もありました。
ただし、これはAndroid向けの説明のようです。

developer.android.com

一方でWeb向けの説明は見つけられず、Compose Multiplatform のページ内の検索でも見つかりませんでした。

kotlinlang.org

rememberSaveableの実装の違い

rememberSaveableのコードをClaude Codeと一緒に読んでいくと、androidx.compose.runtime.saveable.SaveableStateRegistryというインターフェースがあり、その実装クラスのandroidx.compose.runtime.saveable.SaveableStateRegistryImplを生成するときのコンストラタ引数のcanBeSavedに、値を保存できるかの判定ロジックを渡しているのがわかりました。

/**
 * Creates [SaveableStateRegistry].
 *
 * @param restoredValues The map of the restored values
 * @param canBeSaved Function which returns true if the given value can be saved by the registry
 */
public fun SaveableStateRegistry(
    restoredValues: Map<String, List<Any?>>?,
    canBeSaved: (Any) -> Boolean,
): SaveableStateRegistry = SaveableStateRegistryImpl(restoredValues, canBeSaved)

GitHubからコードを引用(リンク)

そして、Android向けとWeb向けに実装が異なったのは、androidx.compose.ui.platform.DisposableSaveableStateRegistryでした。

まず、Android向けのDisposableSaveableStateRegistryの実装です。

  • DisposableSaveableStateRegistryというクラスがあり、SaveableStateRegistryを保持する。
  • SaveableStateRegistryの生成時に、canBeSavedToBundleという関数を渡している。
  • canBeSavedToBundle関数では、AcceptableClassesに定義した型と一致するかをチェックしている。
internal fun DisposableSaveableStateRegistry(
    id: String,
    savedStateRegistryOwner: SavedStateRegistryOwner,
): DisposableSaveableStateRegistry {
    val key = "${SaveableStateRegistry::class.java.simpleName}:$id"

    val androidxRegistry = savedStateRegistryOwner.savedStateRegistry
    val bundle = androidxRegistry.consumeRestoredStateForKey(key)
    val restored: Map<String, List<Any?>>? = bundle?.toMap()

    val saveableStateRegistry = SaveableStateRegistry(restored) { canBeSavedToBundle(it) }
    // (省略)

GitHubからコードを引用(リンク)

private fun canBeSavedToBundle(value: Any): Boolean {
    // (省略)
    for (cl in AcceptableClasses) {
        if (cl.isInstance(value)) {
            return true
        }
    }
    return false
}

GitHubからコードを引用(リンク)

private val AcceptableClasses =
    arrayOf(
        Serializable::class.java,
        Parcelable::class.java,
        String::class.java,
        SparseArray::class.java,
        Binder::class.java,
        Size::class.java,
        SizeF::class.java,
    )

GitHubからコードを引用(リンク)

一方で、Web向け(正確にはAndroid以外)の実装は以下のように、保存できる型の制約がありませんでした。

  • SaveableStateRegistrycanBeSavedで常にtrueを返している
internal fun DisposableSaveableStateRegistry(
    id: String,
    savedStateRegistryOwner: SavedStateRegistryOwner,
): DisposableSaveableStateRegistry {
    val key = "${SaveableStateRegistry::class.simpleName}:$id"

    val androidxRegistry = savedStateRegistryOwner.savedStateRegistry
    val savedState = androidxRegistry.consumeRestoredStateForKey(key)
    val restored: Map<String, List<Any?>>? = savedState?.toMap()

    val saveableStateRegistry = SaveableStateRegistry(restored) { true }
    // (省略)

GitHubからコードを引用(リンク)

まとめ

  • Compose Multiplatform において、rememberSaveableで保存できる型の制約がAndroidだけにあるのがわかった
  • ビルド時には気づかないので、rememberSaveableを利用する場合には気をつける
  • 今度は技術的に何が要因で違っているかのを調査したい

Androidアプリのストレージ使用量を表示する

Androidアプリのストレージ使用量を表示する

はじめに

コンテンツのダウンロード機能があるアプリのユーザは、どれくらいのストレージ容量を使っているのか気になるものです。

今回はストレージにダウンロードしたファイルサイズを取得する方法を調べました。

取得できるストレージ情報

StorageStatsManagerで取得できる情報

Android O (API Level 26) 以降では、StorageStatsManagerを使用して以下の情報を取得できます:

  • ストレージボリュームの総容量: アプリがマウントされているストレージの物理的な総容量
  • 空き容量: そのストレージボリューム内の利用可能な容量
  • アプリの使用量: 特定のアプリが使用しているストレージサイズ

独自実装で取得する情報

StorageStatsManagerの機能ではありませんが、アプリ独自の実装で以下も取得できます:

実装方法

1. StorageStatsManagerでストレージ容量を取得する

Android O (API Level 26) 以降で利用可能なStorageStatsManagerを使用します:

// StorageStatsManagerのインスタンスを取得
val storageStatsManager = context.getSystemService(StorageStatsManager::class.java)

// アプリがマウントされているストレージのUUIDを取得
val applicationInfo = context.applicationInfo
val storageUuid = applicationInfo.storageUuid

// ストレージボリュームの総容量と空き容量を取得
val totalBytes = storageStatsManager.getTotalBytes(storageUuid)
val freeBytes = storageStatsManager.getFreeBytes(storageUuid)

2. アプリ固有のダウンロードフォルダのサイズを計算する

注意: これはStorageStatsManagerの機能ではありません。アプリが独自に管理するダウンロードフォルダのサイズを取得するには、ファイルシステムAPIを使用して対象ディレクトリを再帰的に走査します:

// アプリ固有のダウンロードディレクトリ
val downloadDir = File("${context.dataDir.absolutePath}/files/downloads")

// ディレクトリ内の全ファイルサイズを合計
val downloadBytes = if (downloadDir.exists()) {
    downloadDir.walkTopDown()
        .filter { it.isFile }
        .sumOf { it.length() }
} else {
    0L
}

walkTopDown()を使用することで、サブディレクトリも含めて全てのファイルを走査できます。

この方法は、アプリが管理する任意のディレクトリに適用できます(例:キャッシュフォルダ、ユーザーがダウンロードしたコンテンツなど)。

3. ファイルサイズを人間が読みやすい形式に変換する

AndroidFormatterクラスを使用すると、バイト数を簡単に読みやすい形式に変換できます:

import android.text.format.Formatter

Formatter.formatShortFileSize(context, bytes)

formatShortFileSizeは自動的に適切な単位(B、KB、MB、GB)を選択して表示してくれます。

注意点

1. APIレベルの確認

StorageStatsManagerAndroid O (API Level 26) 以降でのみ利用可能です。必ずバージョンチェックを行いましょう:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    // StorageStatsManagerを使用
} else {
    // 代替手段を使用するか、機能を無効化
}

2. パフォーマンスの考慮

大量のファイルがあるディレクトリの走査は時間がかかるため:

  • 必ずバックグラウンドスレッド(IOディスパッチャー)で実行
  • 結果をキャッシュして頻繁な再計算を避ける
  • 必要に応じてプログレス表示を実装

3. ストレージボリュームとUUIDについて

getTotalBytes()が返すのは、アプリがマウントされているストレージボリュームの物理的な総容量です。公式ドキュメントによると:

"Returns the total size of the underlying physical media that is hosting the storage volume. This value is best suited for visual display to end users, since it's designed to reflect the total storage size advertised in a retail environment."

重要なポイント:

  • 物理メディアのサイズ: 小売環境で宣伝される容量と一致するよう設計されている
  • 表示用に最適化: エンドユーザーへの表示に適している
  • 1000B単位の計算: 1キロバイト = 1,000バイト、1ギガバイト = 1,000,000,000バイトとして計算される(1024ではない)

サイズ計算の注意点

StorageStatsManagerで取得されるサイズは、一般的なコンピュータサイエンスの2進数計算(1024バイト = 1KB)ではなく、10進数計算(1000バイト = 1KB)を使用します。これは小売業界の標準に合わせたものです:

// StorageStatsManagerの値は10進数ベース
val totalBytes = storageStatsManager.getTotalBytes(uuid)  // 1GB = 1,000,000,000バイト

// ファイルサイズ計算は2進数ベース
val fileSize = File("example.txt").length()  // 通常は1024バイト = 1KBで計算される

まとめ

StorageStatsManagerを使用することで、Androidアプリでストレージ情報を簡単に取得できることがわかりました。ダウンロードコンテンツのサイズを表示することで、ユーザーがストレージ管理をしやすくなります。

実装時のポイント:

  • Android O以降で利用可能なStorageStatsManagerを使用
  • ファイルサイズの計算はwalkTopDown()再帰的に実行
  • Formatter.formatShortFileSizeで読みやすい形式に変換
  • 非同期処理でUIをブロックしない

サンプルコードはGitHubで公開しています: https://github.com/Takahana/AndroidStorageProfiler

参考資料