2025年12月4日木曜日

adk-java を Android アプリに組み込むための長い道のり

この記事は JP_Google Developer Experts Advent Calendar 2025 の4日目の記事です。

adk-java ライブラリをアプリに追加する


adk-java を Android アプリに入れるのなんて簡単でしょ。 https://google.github.io/adk-docs/#java に書いてある通りに build.gradle.kts に dependencies { implementation("com.google.adk:google-adk:0.4.0") } を書けばいいんでしょ。 そう思っていました....


Android Studio の [New Project...] からまっさらなプロジェクトを作って(もちろんこの時点ではビルドできるしアプリも実行できる)、上の依存を一行追加して Sync してビルドすると....

はい、エラー...

Duplicate class io.modelcontextprotocol.json.McpJsonInternal found in modules mcp-core-0.14.0.jar -> mcp-core-0.14.0 (io.modelcontextprotocol.sdk:mcp-core:0.14.0) and mcp-json-0.14.0.jar -> mcp-json-0.14.0 (io.modelcontextprotocol.sdk:mcp-json:0.14.0) ...


同じクラスファイル(io.modelcontextprotocol.json.McpJsonInternal)が異なる2つのライブラリ/モジュール(mcp-coreとmcp-json)から重複して含まれてますね...
ということで片方を exclude します。全部で3のライブラリで重複があるようです。 dependencies { implementation("com.google.adk:google-adk:0.4.0") { exclude(group = "io.modelcontextprotocol.sdk", module = "mcp-json") exclude(group = "javax.annotation", module = "javax.annotation-api") exclude(group = "org.slf4j", module = "jcl-over-slf4j") } ... }

これでうまくいくかと思いきや、またしてもエラー

Invalid build configuration. Attempt to create a global synthetic for 'Record desugaring' without a global-synthetics consumer.


adk-java の中で Java 16 以降で導入された Record クラスを使用しているが、それを古い Android バージョンでも動作させるための処理(Desugaring: デシュガーリング)が設定されてないと言われていますね...

一番簡単な解決策はプロジェクトの Javaの互換性バージョンを Java 17 に統一することなのでそうします。 kotlin { jvmToolchain(17) } android { ... compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } }

今度こそビルド通るかと思いきや、またしてもエラー

MethodHandle.invoke and MethodHandle.invokeExact are only supported starting with Android O (--min-api 26): Lautovalue/shaded/com/google/common/hash/ChecksumHashFunction$ChecksumMethodHandles;->updateByteBuffer(Ljava/util/zip/Checksum;Ljava/nio/ByteBuffer;)Z


MethodHandle.invoke と MethodHandle.invokeExact は Android O 以降じゃないとサポートしてないと言われています...

ということで minSdk バージョンを 26 にします。 android { ... defaultConfig { ... minSdk = 26 ... } ... }

4度目...さすがにビルド通ってほしいが、まだエラー

Execution failed for task ':app:mergeDebugJavaResource'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.MergeJavaResWorkAction
> 2 files found with path 'mozilla/public-suffix-list.txt' from inputs:
- org.apache.httpcomponents:httpclient:4.5.14/httpclient-4.5.14.jar
- org.apache.httpcomponents.client5:httpclient5:5.3.1/httpclient5-5.3.1.jar



異なる2つのライブラリに、全く同じパスとファイル名を持つリソースファイルが存在するという典型的なエラーです。
競合しているファイル(このエラーだと mozilla/public-suffix-list.txt)をビルドに含めないように設定します。
一つ対応しても同じエラーが別のファイルに対して何回も出てくるので、全部 excludes に追加します。 android { ... packaging { resources { excludes += "mozilla/public-suffix-list.txt" excludes += "META-INF/DEPENDENCIES" excludes += "META-INF/LICENSE.md" excludes += "META-INF/NOTICE.md" excludes += "META-INF/io.netty.versions.properties" excludes += "META-INF/INDEX.LIST" } } }

これでようやくビルドが通りました!エラーが多い...

都市の時間を返すエージェントを作ってみる


ようやくビルドが通ったので Quickstart (https://google.github.io/adk-docs/get-started/java/) の HelloTimeAgent を作ってみました。

が、これもハードルが高かった〜

API Key どう渡す?


まず困ったのが API key の渡し方。公式ドキュメントには

echo 'export GOOGLE_API_KEY="YOUR_API_KEY"' > .env

しろとしか書いてない。しかし Android は環境変数は使えません。

そこで、API key がセットされなくてクラッシュするところから呼び出し元をたどっていくと、LlmRegistry にデフォルト登録される LlmFactory を利用していることがわかりました。 public final class LlmRegistry { ... /** Map of model name patterns regex to factories. */ private static final Map<String, LlmFactory> llmFactories = new ConcurrentHashMap<>(); /** Registers default LLM factories, e.g. for Gemini models. */ static { registerLlm("gemini-.*", modelName -> Gemini.builder().modelName(modelName).build()); registerLlm("apigee/.*", modelName -> ApigeeLlm.builder().modelName(modelName).build()); } この LlmRegistry に登録されている "gemini-.*" に対応する LlmFactory を、API key をセットした Gemini に置き換えればいけそうだとわかりました。 object HelloTimeAgent { init { LlmRegistry.registerLlm("gemini-.*") { modelName -> Gemini.builder() .modelName(modelName) .apiKey("MY API KEY HERE") .build() } } ... } これで API key がセットされてなくてクラッシュすることはなくなりました。

tool 名の statec method がないと言われる


Tool はリフレクションで呼ばれていて、Java の static メソッドになっている必要がありました。 public class FunctionTool extends BaseTool { ... public static FunctionTool create(Class<?> cls, String methodName, boolean requireConfirmation) { for (Method method : cls.getMethods()) { if (method.getName().equals(methodName) && Modifier.isStatic(method.getModifiers())) { return create(null, method, requireConfirmation); } } throw new IllegalArgumentException( String.format("Static method %s not found in class %s.", methodName, cls.getName())); } Java の static メソッドになっていないとこんなエラーでクラッシュします。

Caused by: java.lang.IllegalArgumentException: Static method getCurrentTime not found in class com.example.myapplication.HelloTimeAgent.

Kotlin の object のメソッドを Java の static メソッドにするには @JvmStatic をつけます。 object HelloTimeAgent { ... @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( @Annotations.Schema( name = "timeZone", description = "timeZone of the city to get the time for" ) timeZone: String ): ... ... }

tool の戻り値は Map じゃないとだめ?


以下のように時刻の文字列だけ返せばいいかなと思ったのですが、これではうまく行きませんでした。 object HelloTimeAgent { ... @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( ... ): String { val zoneId = ZoneId.of(timeZone) val zonedDateTime = ZonedDateTime.now(zoneId) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") return zonedDateTime.format(formatter) } } このように Map で返すとうまくいきました。ちなみに timeZone は Map に含めなくても問題ありませんでした。 object HelloTimeAgent { ... @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( ... ): Map<String, String> { val zoneId = ZoneId.of(timeZone) val zonedDateTime = ZonedDateTime.now(zoneId) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") return mapOf( "timeZone" to timeZone, // なくてもよい "forecast" to zonedDateTime.format(formatter) ) } }

うごいたーーー



全体


全体のコードはこんな感じです。 class MainActivity : ComponentActivity() { private val viewModel by viewModels<MainViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { MyApplicationTheme { ChatScreen( messages = viewModel.messages, loading = viewModel.loading, onClickSubmit = { viewModel.submit(it) } ) } } } } @Composable fun ChatScreen( messages: List<MainViewModel.Message>, loading: Boolean, onClickSubmit: (String) -> Unit, ) { Scaffold { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { LazyColumn(modifier = Modifier.weight(1f)) { items(messages) { when (it.from) { MainViewModel.From.User -> { Row { Spacer(Modifier.weight(1f)) Box( Modifier .weight(4f) .padding(8.dp) .background( MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp) ) .padding(8.dp) ) { Text(text = it.text) } } } MainViewModel.From.Agent -> { Row { Box( Modifier .weight(4f) .padding(8.dp) .background( MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp) ) .padding(8.dp) ) { Text(text = it.text) } Spacer(Modifier.weight(1f)) } } } } if (loading) { item("loading") { CircularProgressIndicator( modifier = Modifier .fillMaxWidth() .wrapContentWidth() ) } } } Divider() Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(8.dp) ) { val state = rememberTextFieldState() TextField( state = state, lineLimits = TextFieldLineLimits.SingleLine, enabled = !loading, modifier = Modifier.weight(1f), ) Button( onClick = { onClickSubmit(state.text.toString()) state.clearText() }, enabled = !loading, ) { Text(text = "送信") } } } } } @Preview(showBackground = true) @Composable private fun Preview() { MyApplicationTheme { ChatScreen( messages = listOf( MainViewModel.Message(from = MainViewModel.From.User, text = "東京の時間は?"), MainViewModel.Message(from = MainViewModel.From.Agent, text = "9時です"), ), loading = false, onClickSubmit = {} ) } } class MainViewModel() : ViewModel() { private val runConfig = RunConfig.builder().build() private val runner = InMemoryRunner(HelloTimeAgent.initAgent()) private val session: Session = runner .sessionService() .createSession(runner.appName(), "user1234") .blockingGet() var messages = mutableStateListOf<Message>() var loading by mutableStateOf(false) fun submit(userInput: String) { if (loading) { return } messages.add(Message(from = From.User, text = userInput)) loading = true viewModelScope.launch { val event = withContext(Dispatchers.IO) { val userMsg = Content.fromParts(Part.fromText(userInput)) val events = runner.runAsync(session.userId(), session.id(), userMsg, runConfig) events.filter { it.finalResponse() }.blockingFirst() } messages.add(Message(from = From.Agent, text = event.stringifyContent())) loading = false } } data class Message(val from: From, val text: String) enum class From { User, Agent } } object HelloTimeAgent { init { LlmRegistry.registerLlm("gemini-.*") { modelName -> Gemini.builder() .modelName(modelName) .apiKey("API KEY HERE") .build() } } fun initAgent(): BaseAgent? { return LlmAgent.builder() .name("hello-time-agent") .description("Tells the current time in a specified city") .instruction( """ You are a helpful assistant that tells the current time in a city. Use the 'getCurrentTime' tool for this purpose. """ ) .model("gemini-2.5-flash") .tools(FunctionTool.create(HelloTimeAgent::class.java, "getCurrentTime")) .build(); } @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( @Annotations.Schema( name = "timeZone", description = "timeZone of the city to get the time for" ) timeZone: String ): Map<String, String> { val zoneId = ZoneId.of(timeZone) val zonedDateTime = ZonedDateTime.now(zoneId) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") return mapOf( "timeZone" to timeZone, "forecast" to zonedDateTime.format(formatter) ) } }

2024年12月9日月曜日

骨髄ドナーとして骨髄採取の手術をしました

本記事は mhidaka が建立した Advent Calendar 2024 Vol.1 の 9日目です(Vol. 2 はこちら)。

2024年のとある時期にとある病院で骨髄採取の手術をしました(非血縁者間骨髄移植です)(時期とか病院とかをはっきり書いてはいけないのです)。
なかなか馴染みのないことだと思うので、時系列で起こったことを紹介します。

2022年12月


秋葉原の献血ルームに献血しに行った時に、そこに骨髄バンクの人がいて「今日採血した血で登録できます」って言われたのでじゃあ登録するかって登録しました。
それまで何度も献血してるけど、その時初めて骨髄バンクの人に会ったのでもっといろんなところでやればいいのにって思いました。

手術約4ヶ月前


日本骨髄バンクから「あなたと患者さんのHLA型が一致し、ドナー候補者に選ばれました。」という SMS が届きました。ちょうど旅行中だったのでよく覚えています。
SMS の中の URL から問診票に回答します。その際面談希望の病院を選択するんですけど、一覧に病院の名前しか載ってなくて一個一個 google map で検索してどこが行きやすいか調べないといけなかったので、ぜひマップで一覧できるようにして欲しいです。

SMS から約2週間後


日本骨髄バンクから担当のコーディネーターさんのお知らせが郵送で届き、その後コーディネーターさんから SMS で連絡が来ました。ここからは担当のコーディネーターさんとずっとやりとりします。
まず確認検査の日程調整をします。候補の病院は複数あげてたのですが病院側の都合とこちらの日程が合わなかったりで決まるまで1週間くらいかかってました。

手術10週間前 : 確認検査&面談


ドナーのためのハンドブックに沿って一通り説明を受けました。注射器で骨髄を採取する方法しかないと思ってたんですけど、今は末梢血幹細胞採取という方法もあるということを知りました。
ドナーになるための健康状態を満たしているかの血液検査が必要なので、そのための採血もしました。全部で2時間くらいです。
1週間後くらいの血液検査の結果が郵送されてきました。もしここで健康状態が基準を満たしていない場合コーディネート終了になります。

手術約3週間前 : 最終同意面談


家族が同席して、改めてドナーのためのハンドブックに沿って一通り説明を受け、同意書を作成します。このとき第三者の立会人もいます。採血がないので1時間半くらいです。
骨髄採取と末梢血幹細胞採取で違いがいろいろあり、こちらの方法は承諾できないと言えばその採取方法にはなりません。私は説明を聞いた上でどっちにもそれなりのメリデメがありどっちでもいいなとなったので両方OKと伝えました。ドナーがどちらでもいいよとなった場合患者側がどちらの採取方法にするか決めます。私の場合は骨髄採取になりました。

手術約3週間前 : 健康診断


私の都合で最終同意面談から手術まで最短日程で行くことになり、最終同意面談の次の日に健康診断を受けました。
健康診断は骨髄採取手術を受ける病院で受けます。
  • 採血
  • 検尿
  • レントゲン
  • 心電図
  • 肺機能(一気に息を勢いよく吐くのが難しかった)

手術約2週間前 : 自己血採取1回目


手術のときに骨髄を900ml(私の場合)採取するので貧血になってしまいます。そこで自分の血をあらかじめ取っておいて(自己血貯血)、手術時に体に戻します。
そのための自己血を2回に分けて採取します。最初にヘモグロビン濃度をチェックする採血をして、400ml貯血しました。
このとき麻酔科にも行って全身麻酔の説明を受けました。あと、入院の手続きもしました。

手術約1週間前 : 自己血採取2回目


ヘモグロビン濃度をチェックする採血をして、200ml貯血しました。

手術前日 : 入院


10時に入院(病院に入院するの初めてです)。まずベッドのところで4本採血。昼担当の看護師さんが挨拶にきて体温と血圧を測っていきました。
その後、血液内科の先生とか、麻酔科の先生が挨拶にきました。11時半くらいに昼ごはんがきて、15時半からシャワーを浴びました。健康体なので暇です。PCで仕事してました。
17時すぎに晩御飯が来ました。病院の晩ごはんは早いということがわかりました。夜担当の看護師さんが挨拶にきて体温と血圧を測っていきました。
21時すぎに消灯しました。健康的です。
次の日の9時から手術なので、21時まで飲食可能でそれ以降は食事NGでした。

手術当日


6時半までに500mlくらい水分を摂取しろと言われたので、5時半に目覚ましをかけてうとうとしながら6時半までに500mlの水を飲みました。
6時半から9時の手術まで水分摂取も禁止で、これがつらかったです。
6時あたりに2本採血。

8時55分くらいに看護師がきて手術室に移動します。乗って帰ってくるストレッチャーも一緒に連れて行きます。
キャップをかぶって手術エリアに入りました。手術エリアすごい広かったです。手術室がたくさんあって、自分の手術室に行くまでまぁまぁ歩きました。
手術台の横のストレッチャーに仰向けに寝て、心電図のシール貼ったり、点滴の注射したり、血圧計つけたり、いろいろしている間に酸素マスクつけられて「大きく深呼吸してください。だんだん眠くなります」っていわれて3回目くらいで寝ました。

名前を呼ばれてはっと気づいたら終わってました。
目覚めたのは手術室なんですが、そこからストレッチャーで病室まで移動したところはまた寝てたっぽくて覚えてません。
病室のベットによっこいせと移されたあたりでまた起きました。
そこから3時間安静にしていないといけないのですが、腰が痛いのでなかなか寝られず、寝れば一瞬なのになーと思ってまぁまぁつらかったです。

術後の感じとしては採取した部位が痛むのと、喉がガラガラになった(全身麻酔なので気管挿管するからです)くらいで、気持ち悪いとかなくてよかったです。
微熱(37.5くらい)と偏頭痛(これは多分貧血由来)があったけど、腰の痛みも含めて痛み止めの薬で全然我慢できる程度でした。

3時間後は普通に歩けるけど、血が足りない感じなので大人しくごろごろしてました。
朝からなにも食べてないので早く晩御飯こないかなーと思ってました。

術後2日目


今日も2本採血。朝ごはんのあと院内をちょっと散歩しました。
歩くのは全然問題なかったのですが、ちょっとくらっとしたので早めに戻りました。
担当医が来て傷口のチェックをして絆創膏を張り替えました。
シャワー入れるか聞いたら、絆創膏の上からサランラップみたいなぴっちりしたシートを貼ってくれました。
担当医が来てヘモグロビンが10切ってるから鉄剤出しますね、って言われました。
昼ごはんの後16時半にシャワー、17時すぎくらいに晩御飯、19時くらいに担当医が来て絆創膏を張り替え、21時消灯

術後3日目


この日は採血なし。
めまいが酷くて、ベットで起き上がったらそのままふらーっと倒れてしまって笑った。貧血で倒れるひとってこんな感じなのか。
7時すぎ朝ごはん、晩御飯が17時すぎくらいなのに朝ごはん遅いんですよねー...。
昨日よりもめまいが酷くてごろごろしてました。
9時すぎに退院の手続きをしに行ったのですが、傷口の痛みは我慢できて普通に歩けるけど、めまいのほうがこわい。移動はゆっくり。
病室に戻って水分不足かもなーと思いスポドリを買いに行った帰りに担当に会って少し話
その後担当医が病室に来て傷口チェック。
10時少し前に予定通り退院しました。

術後4日目


まだめまいがある。昨日よりはましな感じだけど、ほぼ1日ごろごろしてました。
傷口に貼っていた絆創膏のテープの部分がすこし荒れて痒くなってきた。

術後5日目


昨日よりはましになったけど、まだ血が足りてない感がありました。
傷部分はかなり治ってきているのか押してもそこまで痛くなくなりました。

退院時に処方された薬
  • 痛み止め : ロキソプロフェン錠(60mg)
  • 鉄剤 : クエン酸第一鉄Na錠(50mg)

術後約4週間 : 術後検診


手術した病院で検診。体重とか血圧とかの他は検尿と採血のみ。ヘモグロビン濃度も正常値に戻って問題なしでした。

感想

  • なぜドナーになったのか? : やったことなかったでやってみたかった
  • 術後1週間経たずに普通に日常生活に戻れたのでよかったです。
  • 私は比較的仕事の都合がつく方だけど、仕事の都合つけるの大変だろうなと思いました(手術以外にも面談とか健康診断とか自己血採取で何回も病院にいかないといけない)。
  • もし次の機会があってもやると思う(骨髄採取は人生で2回まで可能)。


追記

  • 手術1ヶ月前くらいからサプリは飲まないでくださいって言われて、BCAA もプロテインも飲まないで欲しいと言われました。
  • 手術1ヶ月前くらいから激しい運動は控えてくださいって言われて、ランニングはその時期やめてました。屋内ならいいかなと思ってリングフィットはしてました。
  • 病院ではいっさい支払いしません。私の入院費や検査費は患者さんの健康保険から出ます。
  • 病院食がご飯多くておかず少なくてちょっと物足りなかったです。写真載せたいけど病院が特定できる写真は載せられないのです。
  • 術後2回くらいアンケート依頼の手紙が来ます。数週間後と数ヶ月後だったはず。
  • 患者さんの情報は性別・年代・住んでる地域(関東圏とか)は教えてもらえます。
  • 私の入院日数は3泊4日です。骨髄採取では平均的な日数だと思う。
  • 末梢血幹細胞採取は、普段は骨髄にある造血幹細胞を血管内に出てくるようにする薬を飲んで、成分献血みたいなやつで採取する方法です。成分献血みたいなやつを6時間くらいするそうです。手術をしないので体への負担は骨髄採取より軽いと思うけど入院日数は長くなります。でも自己血貯血しなくていいので病院に行く回数は骨髄採取より少なくなります。
  • 骨髄バンクの登録者数と年間の手術数(骨髄採取+末梢血幹細胞採取)からざっと計算したら、登録期間中に1回ドナー候補になるかどうかくらいの割合だなって思いました。血液型と同じで白血球の型も多いやつ少ないやつあるので、何回も候補になる人はわりといるそうです。

2024年11月17日日曜日

Component が任意のタイミングで更新される構成になっている Dagger を Hilt に移行する

Hilt が入っていない Dagger のみの構成があります。 @Singleton @Component( modules = [ AppModule::class, ] ) interface AppComponent { fun inject(app: MainActivity) } @Module class AppModule { @Singleton @Provides fun provideNeedResetRepository(myApi: MyApi): NeedResetRepository { return NeedResetRepository(myApi) } @Singleton @Provides fun provideMyApi(): MyApi { return MyApi() } } class NeedResetRepository( private val myApi: MyApi ) { // キャッシュなどのデータを持っている } class MyApi { // 状態を持たない } この構成では MyApplication で Dagger の Component である AppComponent のインスタンスを保持しています。

ログアウト時に NeedResetRepository で保持しているキャッシュを削除したいので、MyApplication で保持している AppComponent のインスタンスを null にしてから MainActivity を作り直しています(reset() メソッドのところ)。

(Hilt がない時代の標準的なやり方だと AppComponent のインスタンスは lateinit var にして onCreate() で代入し、作り直すことがないようにすることが多いのですが、それだと Hilt への移行で困ることがないので、今回は困るパターンということでこのような構成例になっています) class MyApplication : Application() { private var appComponent: AppComponent? = null fun getAppComponent(): AppComponent { return appComponent ?: DaggerAppComponent.builder() .build() .also { appComponent = it } } fun reset() { appComponent = null TaskStackBuilder.create(this) .addNextIntent( Intent(this, MainActivity::class.java) ) .startActivities() } } 以下のコードで MainActivity の reset ボタンを押すと、AppComponent のインスタンスが新しくなるので AppComponent および NeedResetRepository のインスタンスが新しくなるのがわかります。

AppComponent と NeedResetRepository には @Singleton がついているので、AppComponent が作り直されるまで NeedResetRepository のインスタンスは変わりません。そのため例えば画面回転時には NeedResetRepository のインスタンスは変わりません。 class MainActivity : ComponentActivity() { @Inject lateinit var needResetRepository: NeedResetRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val appComponent = (application as MyApplication).getAppComponent() appComponent.inject(this) enableEdgeToEdge() setContent { MaterialTheme { Scaffold { innerPadding -> Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .padding(innerPadding) .padding(16.dp), ) { Text( text = "$needResetRepository", ) Button( onClick = { (application as MyApplication).reset() } ) { Text("reset") } } } } } } } さて、このような構成に対し、新しく作成するクラスや ViewModel では Hilt を使いたいとします。


ステップ1 : Hilt ライブラリの設定


libs.versions.toml [libraries] ... hilt = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "dagger" } [plugins] ... hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } build.gradle plugins { ... alias(libs.plugins.hilt) apply false } app/build.gradle plugins { ... + alias(libs.plugins.hilt) } ... dependencies { - implementation(libs.dagger) - ksp(libs.dagger.compiler) + implementation(libs.hilt) + ksp(libs.hilt.compiler) ... }
hilt の plugin を build.gradle に設定し、dependencies の依存ライブラリを dagger のものから hilt のものに置き換えてビルドすると、次のようなエラーがでます。

[ksp] /.../AppComponent.kt:21: [Hilt] com.example.sample.AppModule is missing an @InstallIn annotation. If this was intentional, see https://dagger.dev/hilt/flags#disable-install-in-check for how to disable this check.

@Module アノテーションがついている AppModule に @InstallIn がついていないと怒られていますが、AppModule は AppComponent 用なので @DisableInstallInCheck をつけてエラーがでないようにします。 @DisableInstallInCheck @Module class AppModule { ... } 最後に MyApplication に @HiltAndroidApp アノテーションをつけます。 @HiltAndroidApp class MyApplication : Application() { ... } ここまでで、ビルドして以前と同じ動作になっているのが確認できます(MainActivity にはまだ @AndroidEntryPoint はつけません!)。


ステップ2 : SubComponent 化


AppModule を分割して、リセットする必要のない MyApi は @InstallIn(SingletonComponent::class) をつけた Module で管理するようにしたいのですが、 @InstallIn(SingletonComponent::class) @Module object AppModule2 { @Singleton @Provides fun provideMyApi(): MyApi { return MyApi() } } こうすると、NeedResetRepository を @Provides しているところで MyApi が見えなくなって

[ksp] /.../AppComponent.kt:17: [Dagger/MissingBinding] com.example.sample.MyApi cannot be provided without an @Inject constructor or an @Provides-annotated method.

このようなエラーが出てしまいます。

そこで、Module を分割する前に、既存の AppComponent を Hilt の SubComponent に変更します。


1. AppComponent のアノテーションを @Subcomponent に変更し、@Subcomponent.Builder をつけた Builder を用意します -@Singleton -@Component( - modules = [ - AppModule::class, - ] -) +@Subcomponent interface AppComponent { + @Subcomponent.Builder + interface Builder { + fun build(): AppComponent + } + fun inject(app: MainActivity) }

2. 任意のタイミングで AppComponent を作り直せるように、AppComponent の親 Component を用意します。ログアウト時にリセットすることを想定して、ここでは AuthComponent という名前にしています。

AuthComponent では SingletonComponent に InstallIn されている型も見えるようにしたいので、parent に SingletonComponent を指定します。

AuthComponent に対応する Scope も用意します。ここでは AuthScope という名前にしています。AuthComponent に @AuthScope をつけます。 @Scope @Retention(AnnotationRetention.RUNTIME) annotation class AuthScope @AuthScope @DefineComponent(parent = SingletonComponent::class) interface AuthComponent { @DefineComponent.Builder interface Builder { fun build(): AuthComponent } } AppComponent を AuthComponent に紐づけるための Module を用意します。@InstallIn(AuthComponent::class) をつけ、@Module の subcomponents で AppComponent を指定します。 @InstallIn(AuthComponent::class) @Module( subcomponents = [ AppComponent::class, ] ) interface AuthModule

3. AppModule の @DisableInstallInCheck を @InstallIn(AuthComponent::class) に変更し、@Singleton を @AuthScope に変更します。 -@DisableInstallInCheck +@InstallIn(AuthComponent::class) @Module object AppModule { - @Singleton + @AuthScope @Provides fun provideNeedResetRepository(myApi: MyApi): NeedResetRepository { return NeedResetRepository(myApi) } - @Singleton + @AuthScope @Provides fun provideMyApi(): MyApi { return MyApi() } }

4. 任意のタイミングで AppComponent を作り直すために、AppComponent の親である AuthComponent を管理する GeneratedComponentManager を用意します。 @Singleton class AuthComponentRegistry @Inject constructor( private val authComponentBuilder: AuthComponent.Builder, ) : GeneratedComponentManager<AuthComponent> { private var authComponent: AuthComponent init { authComponent = authComponentBuilder.build() } fun reset() { authComponent = authComponentBuilder.build() } override fun generatedComponent(): AuthComponent { return authComponent } fun getAppComponent(): AppComponent { return EntryPoints.get( this, AuthComponentEntryPoint::class.java ) .appComponentBuilder() .build() } @EntryPoint @InstallIn(AuthComponent::class) interface AuthComponentEntryPoint { fun appComponentBuilder(): AppComponent.Builder } } reset() メソッドを用意して、そこで authComponent のインスタンスを作り直すことによって、任意のタイミングで AppComponent を作り直せるようにしています。

AppComponent.Builder は AuthModule で @InstallIn(AuthComponent::class) されているので、同じように @InstallIn(AuthComponent::class) をつけた EntryPoint を用意することで取得できます。
この EntryPoint のインスタンスは、EntryPoints.get() に AuthComponentRegistry インスタンスを渡すことで取得できます。



5. MyApplication で保持している appComponent を削除して、getAppComponent() では AuthComponentRegistry から取得したインスタンスを返すようにします。reset() メソッドでは AuthComponentRegistry の reset() メソッドを呼ぶように変更します。 @HiltAndroidApp class MyApplication : Application() { - private var appComponent: AppComponent? = null + @Inject + lateinit var authComponentRegistry: AuthComponentRegistry fun getAppComponent(): AppComponent { - return appComponent ?: DaggerAppComponent.builder() - .build() - .also { - appComponent = it - } + return authComponentRegistry.getAppComponent() } fun reset() { - appComponent = null + authComponentRegistry.reset()

これで AppComponent が Hilt で管理されるようになりました。

reset ボタンが押されるまでは NeedResetRepository のインスタンスが保持され、reset ボタンを押すと NeedResetRepository のインスタンスが新しくなるという以前の挙動を保っています。



ステップ3 : Module 分割


ここまでくれば MyApi を別 Module に分割できます。 @InstallIn(AuthComponent::class) @Module object AppModule { @AuthScope @Provides fun provideNeedResetRepository(myApi: MyApi): NeedResetRepository { return NeedResetRepository(myApi) } +} - @AuthScope +@InstallIn(SingletonComponent::class) +@Module +object AppModule2 { + + @Singleton @Provides fun provideMyApi(): MyApi { return MyApi() } } AppModule2 には @InstallIn(SingletonComponent::class) をつけ、provideMyApi() の scope を @AuthScope から @Singleton に変更します。 reset ボタンが押されても MyApi のインスタンスが保持されるように変わります。



ステップ4 : @HiltViewModel


この段階で、AuthComponent に依存しないクラスだけを引数にとる ViewModel なら @HiltViewModel を使えるようになります。 @HiltViewModel class SomeViewModel @Inject constructor( prival val myApi: MyApi ) : ViewModel() @AndroidEntryPoint class SomeActivity : ComponentActivity() { private val viewModel by viewModels<SomeViewModel>() }
AuthComponent に依存するクラスを引数にとるときは AssistedInject を利用します。
https://dagger.dev/hilt/view-model#assisted-injection
@HiltViewModel(assistedFactory = SomeViewModel.Factory::class) class SomeViewModel @AssistedInject constructor( @Assisted private val needResetRepository: NeedResetRepository ) : ViewModel() { @AssistedFactory interface Factory { fun create( needResetRepository: NeedResetRepository ): SomeViewModel } } @AndroidEntryPoint class SomeActivity : ComponentActivity() { private val viewModel by viewModels<SomeViewModel>( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback<SomeViewModel.Factory> { factory -> factory.create( authComponentEntryPoint().needResetRepository() ) } } ) } Context から AuthComponent に依存するクラスのインスタンスを取れるように便利メソッドを用意しておきます。 @EntryPoint @InstallIn(AuthComponent::class) interface AuthComponentEntryPoint { fun needResetRepository(): NeedResetRepository } @InstallIn(SingletonComponent::class) @EntryPoint interface SingletonComponentEntryPoint { fun authComponentRegistry(): AuthComponentRegistry } fun Context.authComponentEntryPoint(): AuthComponentEntryPoint { val authComponentRegistry = EntryPointAccessors .fromApplication<SingletonComponentEntryPoint>(this) .authComponentRegistry() return EntryPoints.get(authComponentRegistry, AuthComponentEntryPoint::class.java) }


ステップ4 : AppComponent の inject() メソッド廃止


上で用意した便利メソッドを使って MainActivity の needResetRepository にインスタンスをセットするように変えます。

MyApi は @InstallIn(SingletonComponent::class) なので MainActivity に @AndroidEntryPoint をつければ inject されます。 +@AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var needResetRepository: NeedResetRepository + private lateinit var needResetRepository: NeedResetRepository @Inject lateinit var myApi: MyApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val appComponent = (application as MyApplication).getAppComponent() - appComponent.inject(this) + needResetRepository = authComponentEntryPoint().needResetRepository() enableEdgeToEdge() interface AppComponent { interface Builder { fun build(): AppComponent } - fun inject(app: MainActivity) } このように AppComponent の inject() メソッドを段階的に廃止していきます。



ステップ5 : AppComponent の廃止


AppComponent に定義されているメソッドがなくなったら AppComponent 自体を廃止します。 -@Subcomponent -interface AppComponent { - - @Subcomponent.Builder - interface Builder { - fun build(): AppComponent - } -} -@InstallIn(AuthComponent::class) -@Module( - subcomponents = [ - AppComponent::class, - ] -) -interface AuthModule @Singleton class AuthComponentRegistry @Inject constructor( private val authComponentBuilder: AuthComponent.Builder, ) : GeneratedComponentManager<AuthComponent> { ... override fun generatedComponent(): AuthComponent { return authComponent } - - fun getAppComponent(): AppComponent { - return EntryPoints.get( - this, - AuthComponentEntryPoint::class.java - ) - .appComponentBuilder() - .build() - } - - @EntryPoint - @InstallIn(AuthComponent::class) - interface AuthComponentEntryPoint { - fun appComponentBuilder(): AppComponent.Builder - } } @HiltAndroidApp class MyApplication : Application() { @Inject lateinit var authComponentRegistry: AuthComponentRegistry - fun getAppComponent(): AppComponent { - return authComponentRegistry.getAppComponent() - } - fun reset() {


これで Hilt への移行完了です!