Kotlin Multiplatformでいちばん厄介なのは「共通コードで書きたいのに、プラットフォームごとに微妙に違う」という現実との格闘だと思います。ネットワーク処理はKtorで統一できても、実際にClaude APIのストリーミングレスポンスを受け取って、UIスレッドに渡して、エラーをきちんとハンドリングして……という一連の流れを本番水準で仕上げようとすると、詰まるポイントが予想以上に多いものです。
私は個人開発でiOSとAndroidのアプリを並行して運用しており、そのうち数本にClaude APIをアプリ内アシスタントとして組み込んでいます。KMPを選んだ理由は単純で、一人で二つのプラットフォームを別々に追いかけ続けるのが現実的でなくなったからです。
ただ、iOS単独やAndroid単独で書いたコードをKMPへ持ち込むと、想定外のところで挙動が変わりました。しかもその多くは、ビルドが通ってしまうために気づくのが遅れる種類のものでした。
このガイドはその実装記録です。Claude API を KMP 上で「動かす」段階から、AdMob 連携アプリの中で 24 時間安定して回し続ける段階まで、私が個人開発者として実際に通った設計判断・落とし穴・運用知見を、コードと数値の両方で残しておきます。読者の方が同じ場所でつまずかないことが、書く側のいちばんの目的です。
プロジェクト構成の設計方針
まず、KMPでClaude APIを統合するときのフォルダ構成を示します。
shared/
├── src/
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── com/example/ai/
│ │ ├── ClaudeClient.kt # APIクライアント本体
│ │ ├── ClaudeModels.kt # リクエスト/レスポンスモデル
│ │ ├── StreamingHandler.kt # ストリーミング処理
│ │ └── RetryPolicy.kt # リトライ・エラーハンドリング
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── com/example/ai/
│ │ └── PlatformClient.android.kt # Android固有実装
│ └── iosMain/
│ └── kotlin/
│ └── com/example/ai/
│ └── PlatformClient.ios.kt # iOS固有実装
androidApp/
iosApp/
なぜこの構成か : Claude APIとの通信ロジック(リクエスト生成・パース・リトライ)はプラットフォーム差異がないため、commonMainに集中させます。プラットフォーム固有の処理(SSL証明書ピニング、システムキーチェーンへのAPIキー保存など)だけをandroidMain/iosMainに分離する設計です。この分離を徹底することで、バグ修正や機能追加を一箇所に留められます。
Step 1: Gradle設定とKtor Clientの導入
// shared/build.gradle.kts
plugins {
kotlin ( "multiplatform" )
kotlin ( "plugin.serialization" )
id ( "com.android.library" )
}
kotlin {
androidTarget ()
listOf (
iosX64 (),
iosArm64 (),
iosSimulatorArm64 ()
). forEach {
it.binaries. framework {
baseName = "shared"
}
}
sourceSets {
commonMain. dependencies {
// Ktor — KMP対応のHTTPクライアント
implementation ( "io.ktor:ktor-client-core:3.1.2" )
implementation ( "io.ktor:ktor-client-content-negotiation:3.1.2" )
implementation ( "io.ktor:ktor-serialization-kotlinx-json:3.1.2" )
implementation ( "io.ktor:ktor-client-logging:3.1.2" )
// kotlinx.serialization — JSONパース
implementation ( "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0" )
// kotlinx.coroutines — 非同期処理
implementation ( "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1" )
}
androidMain. dependencies {
// Android向けKtorエンジン(OkHttpが推奨)
implementation ( "io.ktor:ktor-client-okhttp:3.1.2" )
}
iosMain. dependencies {
// iOS向けKtorエンジン(DarwinはNSURLSessionベース)
implementation ( "io.ktor:ktor-client-darwin:3.1.2" )
}
}
}
ポイント : iOSではOkHttpは使えません。DarwinエンジンがNSURLSessionをラップしており、iOS固有のSSLやプロキシ設定と自然に統合できます。AndroidでOkHttpを選ぶのは、接続プールやHTTP/2サポートが充実しているためです。
Step 2: APIクライアントの実装(commonMain)
// shared/src/commonMain/kotlin/com/example/ai/ClaudeClient.kt
import io.ktor.client. *
import io.ktor.client.plugins.contentnegotiation. *
import io.ktor.client.plugins.logging. *
import io.ktor.client.request. *
import io.ktor.client.statement. *
import io.ktor.http. *
import io.ktor.serialization.kotlinx.json. *
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
class ClaudeClient (
private val apiKey: String ,
private val httpClient: HttpClient = createHttpClient ()
) {
companion object {
private const val BASE_URL = "https://api.anthropic.com/v1"
private const val API_VERSION = "2023-06-01"
private const val DEFAULT_MODEL = "claude-sonnet-4-6"
private const val DEFAULT_MAX_TOKENS = 4096
}
// SSE のパースで毎行使うため、インスタンスは 1 つに固定します
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* 通常のメッセージ送信(レスポンス全体を一括取得)
* シンプルなQ&Aやオフライン処理に適しています
*/
suspend fun sendMessage (
messages: List < ClaudeMessage >,
systemPrompt: String ? = null ,
model: String = DEFAULT_MODEL,
maxTokens: Int = DEFAULT_MAX_TOKENS
): Result < ClaudeResponse > = runCatching {
val request = ClaudeRequest (
model = model,
maxTokens = maxTokens,
system = systemPrompt,
messages = messages,
stream = false
)
val response = httpClient. post ( " $BASE_URL /messages" ) {
header ( "x-api-key" , apiKey)
header ( "anthropic-version" , API_VERSION)
contentType (ContentType.Application.Json)
setBody (request)
}
if ( ! response.status. isSuccess ()) {
val errorBody = response. bodyAsText ()
throw ClaudeApiException (
statusCode = response.status. value ,
message = parseErrorMessage (errorBody)
)
}
response. body < ClaudeResponse >()
}
/**
* ストリーミングメッセージ送信
* テキスト生成の進行状況をリアルタイムでUIに反映できます
* Flowで各チャンクを順次emitします
*/
fun sendMessageStreaming (
messages: List < ClaudeMessage >,
systemPrompt: String ? = null ,
model: String = DEFAULT_MODEL,
maxTokens: Int = DEFAULT_MAX_TOKENS
): Flow < StreamEvent > = flow {
val request = ClaudeRequest (
model = model,
maxTokens = maxTokens,
system = systemPrompt,
messages = messages,
stream = true
)
// SSE(Server-Sent Events)形式でレスポンスを受信します
httpClient. preparePost ( " $BASE_URL /messages" ) {
header ( "x-api-key" , apiKey)
header ( "anthropic-version" , API_VERSION)
header ( "Accept" , "text/event-stream" )
contentType (ContentType.Application.Json)
setBody (request)
}. execute { response ->
if ( ! response.status. isSuccess ()) {
throw ClaudeApiException (
statusCode = response.status. value ,
message = "Streaming request failed: ${response.status}"
)
}
// バイトストリームをSSEイベントに変換します
val channel = response. bodyAsChannel ()
while ( ! channel.isClosedForRead) {
// 1行ずつ読み込みます(SSE形式: "data: {...}")
val line = channel. readUTF8Line () ?: break
when {
line. startsWith ( "data: " ) -> {
// ⚠️ Claude API は [DONE] を送りません。終端は message_stop です
// (理由は後述「ストリームはどこで終わるのか」)
val event = parseStreamEvent (line. removePrefix ( "data: " ))
if (event != null ) {
emit (event)
if (event is StreamEvent.Done) return @execute
}
}
line == "" -> {
// SSEイベントの区切り(空行)
}
}
}
}
}
private fun parseErrorMessage (body: String ): String {
return try {
// エラーレスポンスから message フィールドを抽出
val error = json. decodeFromString < ApiErrorResponse >(body)
error.error.message
} catch (e: Exception ) {
"API error: $body "
}
}
private fun parseStreamEvent ( data : String ): StreamEvent ? {
// Json インスタンスは呼び出しごとに作らず、クラスのプロパティとして 1 つ持ちます。
// ストリーミングでは 1 応答あたり数百回呼ばれるため、生成コストが無視できません。
val event = try {
json. decodeFromString < RawStreamEvent >( data )
} catch (e: Exception ) {
return null // 未知の形は捨てて構いません(ping 等)
}
return when (event.type) {
"content_block_delta" ->
// thinking_delta / input_json_delta を text と取り違えないよう型で絞ります
event.delta
?. takeIf { it.type == "text_delta" }
?.text
?. let { StreamEvent. TextDelta (it) }
"message_start" -> StreamEvent. MessageStart (event.message?.usage?.inputTokens ?: 0 )
"message_delta" -> StreamEvent. Usage (event.usage?.outputTokens ?: 0 )
"message_stop" -> StreamEvent.Done
"error" -> StreamEvent. Failed (
type = event.error?.type ?: "unknown_error" ,
message = event.error?.message ?: "stream error" ,
)
else -> null // ping・content_block_start / _stop はここで落ちます
}
}
}
Step 3: データモデルの定義
// shared/src/commonMain/kotlin/com/example/ai/ClaudeModels.kt
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// --- リクエストモデル ---
@Serializable
data class ClaudeRequest (
val model: String ,
@SerialName ( "max_tokens" ) val maxTokens: Int ,
val system: String ? = null ,
val messages: List < ClaudeMessage >,
val stream: Boolean = false
)
@Serializable
data class ClaudeMessage (
val role: String , // "user" または "assistant"
val content: String
) {
companion object {
fun user (text: String ) = ClaudeMessage ( "user" , text)
fun assistant (text: String ) = ClaudeMessage ( "assistant" , text)
}
}
// --- レスポンスモデル ---
@Serializable
data class ClaudeResponse (
val id: String ,
val type: String ,
val role: String ,
val content: List < ContentBlock >,
val model: String ,
@SerialName ( "stop_reason" ) val stopReason: String ? = null ,
val usage: UsageInfo
) {
// 最初のテキストコンテンツを取得するヘルパー
val text: String get () = content. firstOrNull ()?.text ?: ""
}
@Serializable
data class ContentBlock (
val type: String ,
val text: String = ""
)
@Serializable
data class UsageInfo (
@SerialName ( "input_tokens" ) val inputTokens: Int ,
@SerialName ( "output_tokens" ) val outputTokens: Int
)
// --- ストリーミングイベント ---
sealed class StreamEvent {
data class TextDelta ( val text: String ) : StreamEvent ()
data class MessageStart ( val inputTokens: Int ) : StreamEvent ()
/** message_delta が運んでくる累積の出力トークン数 */
data class Usage ( val outputTokens: Int ) : StreamEvent ()
/** ストリーム途中で届く event: error(HTTP は 200 のまま) */
data class Failed ( val type: String , val message: String ) : StreamEvent ()
/** message_stop = ストリームの正常終端 */
data object Done : StreamEvent ()
}
// --- ストリーミング内部モデル ---
@Serializable
data class RawStreamEvent (
val type: String ,
val delta: DeltaContent ? = null ,
val message: MessageContent ? = null ,
val usage: UsageContent ? = null ,
val error: ApiError ? = null
)
@Serializable
data class DeltaContent (
val type: String = "" ,
val text: String = ""
)
@Serializable
data class MessageContent (
val usage: UsageInfo ? = null
)
@Serializable
data class UsageContent (
@SerialName ( "output_tokens" ) val outputTokens: Int = 0
)
// --- エラーモデル ---
@Serializable
data class ApiErrorResponse (
val type: String ,
val error: ApiError
)
@Serializable
data class ApiError (
val type: String ,
val message: String
)
class ClaudeApiException (
val statusCode: Int ,
override val message: String
) : Exception ( message ) {
val isRateLimit: Boolean get () = statusCode == 429
val isServerError: Boolean get () = statusCode >= 500
val isAuthError: Boolean get () = statusCode == 401
}
Step 4: プラットフォーム固有実装(expect/actual)
HttpClientの生成はプラットフォーム別に異なります。expect/actualパターンで共通インターフェースを定義します。
// shared/src/commonMain/kotlin/com/example/ai/PlatformClient.kt
expect fun createHttpClient (): HttpClient
// shared/src/androidMain/kotlin/com/example/ai/PlatformClient.android.kt
import io.ktor.client. *
import io.ktor.client.engine.okhttp. *
import io.ktor.client.plugins.contentnegotiation. *
import io.ktor.client.plugins.logging. *
import io.ktor.serialization.kotlinx.json. *
import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit
actual fun createHttpClient (): HttpClient = HttpClient (OkHttp) {
engine {
config {
// 接続タイムアウト: AI生成は時間がかかるため余裕を持たせます
connectTimeout ( 30 , TimeUnit.SECONDS)
readTimeout ( 120 , TimeUnit.SECONDS) // ストリーミングは特に長くなります
writeTimeout ( 30 , TimeUnit.SECONDS)
}
}
install (ContentNegotiation) {
json ( Json {
ignoreUnknownKeys = true // APIの新フィールドへの耐性を確保します
isLenient = true
})
}
install (Logging) {
level = LogLevel.HEADERS // 本番ではBODYログを避けます(APIキー露出リスク)
logger = object : Logger {
override fun log (message: String ) {
android.util.Log. d ( "ClaudeClient" , message)
}
}
}
}
// shared/src/iosMain/kotlin/com/example/ai/PlatformClient.ios.kt
import io.ktor.client. *
import io.ktor.client.engine.darwin. *
import io.ktor.client.plugins.contentnegotiation. *
import io.ktor.client.plugins.logging. *
import io.ktor.serialization.kotlinx.json. *
import kotlinx.serialization.json.Json
actual fun createHttpClient (): HttpClient = HttpClient (Darwin) {
engine {
configureRequest {
setAllowsCellularAccess ( true )
}
// iOS 16以降: pipeliningでネットワーク切替時の接続維持が改善されます
pipelining = true
}
install (ContentNegotiation) {
json ( Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install (Logging) {
level = LogLevel.INFO
}
}
タイムアウト設定が重要な理由 : Claude APIのレスポンス生成は、特に長文出力や複雑なリクエストで数十秒かかることがあります。デフォルトのタイムアウト(多くの場合10〜15秒)では正常なレスポンスが途中で切断されてしまいます。readTimeout(120, TimeUnit.SECONDS)は過剰に見えますが、ストリーミングでの長文生成では必要な余裕です。
Step 5: リトライポリシーの実装
本番アプリでは、ネットワーク断やレート制限への対処が不可欠です。
// shared/src/commonMain/kotlin/com/example/ai/RetryPolicy.kt
import kotlinx.coroutines.delay
import kotlin.math.min
import kotlin.math.pow
class RetryPolicy (
private val maxRetries: Int = 3 ,
private val baseDelayMs: Long = 1000L ,
private val maxDelayMs: Long = 30_000L
) {
/**
* 指数バックオフでリトライを実行します
*
* リトライすべき条件:
* - 429 (Rate Limit): Anthropicのレート制限に達した場合
* - 500/502/503 (Server Error): Anthropicサーバーの一時障害
*
* リトライしてはいけない条件:
* - 401 (Auth): APIキーが無効 — リトライしても解決しません
* - 400 (Bad Request): リクエスト形式エラー — リトライしても解決しません
*/
suspend fun < T > execute (block: suspend () -> T): T {
var lastException: Exception ? = null
repeat (maxRetries + 1 ) { attempt ->
try {
return block ()
} catch (e: ClaudeApiException ) {
lastException = e
if ( ! e.isRetryable) {
// リトライ不可なエラーは即座に再スローします
throw e
}
if (attempt < maxRetries) {
val delayMs = calculateDelay (attempt, e.isRateLimit)
delay (delayMs)
}
}
}
throw lastException ?: IllegalStateException ( "Retry failed with unknown error" )
}
private fun calculateDelay (attempt: Int , isRateLimit: Boolean ): Long {
// レート制限の場合は長めに待ちます(最低5秒)
val baseMs = if (isRateLimit) maxOf (baseDelayMs, 5000L ) else baseDelayMs
// 指数バックオフ: 1秒 → 2秒 → 4秒 → ...(最大30秒)
val exponential = (baseMs * 2.0 . pow (attempt)). toLong ()
// ジッターで同時リトライによるスパイクを防ぎます
val jitter = ( 0 .. 500 ). random (). toLong ()
return min (exponential + jitter, maxDelayMs)
}
}
// ClaudeApiExceptionにリトライ可否を追加します
val ClaudeApiException.isRetryable: Boolean
get () = isRateLimit || isServerError
Step 6: ViewModelとの統合パターン
iOSとAndroidで共通のViewModelロジックを実装します。
// shared/src/commonMain/kotlin/com/example/ai/ChatViewModel.kt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow. *
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ChatUiState (
val messages: List < ChatMessage > = emptyList (),
val streamingText: String = "" , // 生成中テキスト(リアルタイム表示用)
val isLoading: Boolean = false ,
val error: String ? = null
)
data class ChatMessage (
val id: String ,
val role: String ,
val text: String ,
val isStreaming: Boolean = false
)
class ChatViewModel (
private val claudeClient: ClaudeClient ,
private val retryPolicy: RetryPolicy = RetryPolicy (),
private val coroutineScope: CoroutineScope
) {
private val _uiState = MutableStateFlow ( ChatUiState ())
val uiState: StateFlow < ChatUiState > = _uiState. asStateFlow ()
// 会話履歴(APIに送信する形式)
private val conversationHistory = mutableListOf < ClaudeMessage >()
// 送信中のジョブ(キャンセル用)
private var currentJob: kotlinx .coroutines.Job? = null
fun sendMessage (userText: String ) {
if (userText. isBlank () || _uiState. value .isLoading) return
// ユーザーメッセージをUIと履歴に追加します
val userMessage = ChatMessage (
id = generateId (),
role = "user" ,
text = userText
)
conversationHistory. add (ClaudeMessage. user (userText))
_uiState. update { state ->
state. copy (
messages = state.messages + userMessage,
isLoading = true ,
error = null ,
streamingText = ""
)
}
currentJob = coroutineScope. launch {
try {
retryPolicy. execute {
var fullText = ""
claudeClient. sendMessageStreaming (
messages = trimmedHistory (),
systemPrompt = "あなたは丁寧で正確なアシスタントです。"
). collect { event ->
when (event) {
is StreamEvent.TextDelta -> {
fullText += event.text
// UIにリアルタイム反映します
_uiState. update { it. copy (streamingText = fullText) }
}
is StreamEvent.Done -> {
// ストリーミング完了: 会話履歴に追加してUIを更新します
conversationHistory. add (ClaudeMessage. assistant (fullText))
val assistantMessage = ChatMessage (
id = generateId (),
role = "assistant" ,
text = fullText
)
_uiState. update { state ->
state. copy (
messages = state.messages + assistantMessage,
streamingText = "" ,
isLoading = false
)
}
}
is StreamEvent.Failed -> {
// HTTP は 200 のまま届くので、ここで拾わないと
// 途中で切れた回答が「成功」として履歴に残ります
throw ClaudeApiException (
statusCode = if (event.type == "overloaded_error" ) 529 else 500 ,
message = event.message,
)
}
else -> { /* MessageStart / Usage は今回は省略 */ }
}
}
}
} catch (e: ClaudeApiException ) {
val errorMessage = when {
e.isAuthError -> "APIキーが無効です。設定を確認してください。"
e.isRateLimit -> "リクエストが多すぎます。しばらく待ってから再試行してください。"
e.isServerError -> "サービスが一時的に利用できません。"
else -> "エラーが発生しました: ${e.message}"
}
_uiState. update { state ->
state. copy (isLoading = false , error = errorMessage)
}
}
}
}
/**
* 進行中のリクエストをキャンセルします
* UIの「停止」ボタンから呼び出します
*/
fun cancelStreaming () {
currentJob?. cancel ()
_uiState. update { it. copy (isLoading = false , streamingText = "" ) }
}
/**
* Context Windowを節約するため直近20件のみ送信します
* 会話が長くなるほどコストとレイテンシが増加するため必要な処理です
*/
private fun trimmedHistory (maxMessages: Int = 20 ): List < ClaudeMessage > {
return conversationHistory. takeLast (maxMessages)
}
private fun generateId (): String = "msg_${
kotlinx.datetime.Clock.System. now (). toEpochMilliseconds ()
}"
}
Step 7: iOS側でのSwiftUI統合
KMPで生成したフレームワークをSwiftUIから使う方法です。
// iosApp/ContentView.swift
import SwiftUI
import shared // KMPで生成したフレームワーク
struct ContentView : View {
@StateObject private var viewModel = ChatViewModelWrapper ()
@State private var inputText = ""
var body: some View {
VStack ( spacing : 0 ) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack ( alignment : .leading, spacing : 12 ) {
ForEach (viewModel.messages, id : \.id) { message in
MessageBubble ( message : message)
. id (message.id)
}
// ストリーミング中のテキストをリアルタイム表示します
if ! viewModel.streamingText. isEmpty {
MessageBubble (
role : "assistant" ,
text : viewModel.streamingText,
isStreaming : true
)
. id ( "streaming" )
}
}
. padding ()
}
. onChange ( of : viewModel.streamingText) { _ in
// 自動スクロールで最新メッセージを常に表示します
withAnimation {
proxy. scrollTo ( "streaming" , anchor : .bottom)
}
}
}
// エラー表示
if let error = viewModel. error {
HStack {
Image ( systemName : "exclamationmark.triangle" )
Text (error)
. font (.caption)
}
. foregroundColor (.red)
. padding (.horizontal)
. padding (.vertical, 8 )
. background (Color.red. opacity ( 0.1 ))
}
// 入力エリア
HStack ( spacing : 12 ) {
TextField ( "メッセージを入力" , text : $inputText, axis : .vertical)
. textFieldStyle (.roundedBorder)
. lineLimit ( 1 ... 5 )
if viewModel.isLoading {
Button ( action : { viewModel. cancelStreaming () }) {
Image ( systemName : "stop.circle.fill" )
. font (.title2)
. foregroundColor (.red)
}
} else {
Button ( action : {
viewModel. sendMessage ( text : inputText)
inputText = ""
}) {
Image ( systemName : "arrow.up.circle.fill" )
. font (.title2)
}
. disabled (inputText. isEmpty )
}
}
. padding ()
}
}
}
// KotlinのStateFlowをSwiftのObservableObjectに変換するラッパーです
class ChatViewModelWrapper : ObservableObject {
private let viewModel: ChatViewModel
@Published var messages: [UIChatMessage] = []
@Published var streamingText: String = ""
@Published var isLoading: Bool = false
@Published var error: String ? = nil
init () {
// APIキーはKeychainから取得します(ハードコード禁止)
let apiKey = KeychainHelper. getApiKey () ?? ""
let client = ClaudeClient ( apiKey : apiKey)
viewModel = ChatViewModel (
claudeClient : client,
retryPolicy : RetryPolicy ( maxRetries : 3 , baseDelayMs : 1000 , maxDelayMs : 30000 ),
coroutineScope : MainScope ()
)
startObserving ()
}
private func startObserving () {
// StateFlowをSwiftで購読します
// プロダクションではSkieライブラリによるasync/await統合も検討してください
viewModel.uiState. collect ( collector : FlowCollector { [ weak self ] state in
guard let state = state as? ChatUiState else { return }
DispatchQueue.main. async {
self ? .messages = state.messages. map { UIChatMessage ( from : $0 ) }
self ? .streamingText = state.streamingText
self ? .isLoading = state.isLoading
self ? . error = state. error
}
})
}
func sendMessage ( text : String ) {
viewModel. sendMessage ( userText : text)
}
func cancelStreaming () {
viewModel. cancelStreaming ()
}
}
Step 8: APIキーの安全な管理
本番アプリでAPIキーをコードやInfo.plistに直書きするのは絶対に避けてください。APKやIPAを展開・解析するツールは広く公開されており、バイナリに埋め込まれたAPIキーは数分で抽出できます。
// Android: EncryptedSharedPreferencesを使います
// shared/src/androidMain/kotlin/com/example/ai/ApiKeyStorage.android.kt
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
actual class ApiKeyStorage ( private val context: android .content.Context) {
private val masterKey = MasterKey. Builder (context)
. setKeyScheme (MasterKey.KeyScheme.AES256_GCM)
. build ()
private val prefs = EncryptedSharedPreferences. create (
context,
"secure_prefs" ,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
actual fun saveApiKey (key: String ) {
prefs. edit (). putString ( "claude_api_key" , key). apply ()
}
actual fun getApiKey (): String ? {
return prefs. getString ( "claude_api_key" , null )
}
}
// iOS: Keychainを使います
// iosApp/KeychainHelper.swift
import Security
import Foundation
struct KeychainHelper {
private static let account = "claude_api_key"
private static let service = Bundle.main.bundleIdentifier ?? "com.example.app"
static func saveApiKey ( _ key: String ) {
guard let data = key. data ( using : . utf8 ) else { return }
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service,
kSecAttrAccount as String : account,
kSecValueData as String : data,
// このデバイスでのみ使用可能(バックアップに含まれません)
kSecAttrAccessible as String : kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete (query as CFDictionary) // 既存エントリを削除
SecItemAdd (query as CFDictionary, nil )
}
static func getApiKey () -> String ? {
let query: [ String : Any ] = [
kSecClass as String : kSecClassGenericPassword,
kSecAttrService as String : service,
kSecAttrAccount as String : account,
kSecReturnData as String : true ,
kSecMatchLimit as String : kSecMatchLimitOne
]
var result: AnyObject ?
let status = SecItemCopyMatching (query as CFDictionary, & result)
guard status == errSecSuccess,
let data = result as? Data,
let key = String ( data : data, encoding : . utf8 ) else {
return nil
}
return key
}
}
テスト戦略 — commonMainのロジックをKMPで検証する
KMPの大きなメリットは、共通ロジックをプラットフォームに依存せずテストできることです。
// shared/src/commonTest/kotlin/com/example/ai/RetryPolicyTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlinx.coroutines.test.runTest
class RetryPolicyTest {
@Test
fun `レート制限エラー後にリトライして成功する` () = runTest {
var attemptCount = 0
val policy = RetryPolicy (maxRetries = 3 , baseDelayMs = 0 , maxDelayMs = 0 )
val result = policy. execute {
attemptCount ++
if (attemptCount < 2 ) {
throw ClaudeApiException ( 429 , "Rate limit exceeded" )
}
"success"
}
assertEquals ( "success" , result)
assertEquals ( 2 , attemptCount)
}
@Test
fun `認証エラーはリトライしない` () = runTest {
var attemptCount = 0
val policy = RetryPolicy (maxRetries = 3 , baseDelayMs = 0 , maxDelayMs = 0 )
assertFailsWith < ClaudeApiException > {
policy. execute {
attemptCount ++
throw ClaudeApiException ( 401 , "Unauthorized" )
}
}
// 1回だけ試みてすぐにスローされます
assertEquals ( 1 , attemptCount)
}
@Test
fun `最大リトライ回数を超えたら最後のエラーをスローする` () = runTest {
val policy = RetryPolicy (maxRetries = 2 , baseDelayMs = 0 , maxDelayMs = 0 )
val exception = assertFailsWith < ClaudeApiException > {
policy. execute {
throw ClaudeApiException ( 503 , "Service Unavailable" )
}
}
assertEquals ( 503 , exception.statusCode)
}
}
class StreamEventTest {
@Test
fun `TextDeltaイベントを正しく蓄積できる` () {
val events = listOf (
StreamEvent. TextDelta ( "Hello" ),
StreamEvent. TextDelta ( ", " ),
StreamEvent. TextDelta ( "world!" ),
StreamEvent.Done
)
var accumulated = ""
events. forEach { event ->
if (event is StreamEvent.TextDelta) {
accumulated += event.text
}
}
assertEquals ( "Hello, world!" , accumulated)
}
}
ストリームはどこで終わるのか — 終端イベントを取り違えていた話
アシスタント機能をベータに配ってすぐ、報告が二種類届きました。「返答は最後まで表示されるのに、送信ボタンが灰色のまま戻らない」。もうひとつは「アプリを開き直すと、さっきの返答だけが履歴から消えている」。
原因は同じ一箇所でした。私は SSE の終端を data: [DONE] で判定していたのです。別のチャット API を触っていた記憶が、そのまま手に残っていたのだと思います。
Claude API のストリームに [DONE] は流れません。終端は event: message_stop(データ側の "type": "message_stop")で通知されます。つまり [DONE] を待つコードでは StreamEvent.Done が一度も発火せず、Step 6 の ViewModel が Done に紐づけていた処理 — 会話履歴への追加と isLoading = false — がまるごと実行されません。
本文そのものは TextDelta で流れてくるので、画面にはきちんと出ます。表示だけが正しくて、状態だけが取り残される。この「半分だけ動く」性質が、気づくまでの時間を延ばしました。
取りこぼしを数える
公式ドキュメントに載っているストリーム応答の例(message_start → content_block_start → ping → content_block_delta → message_delta → message_stop)に、同ドキュメントのエラー例である event: error を一件だけ混ぜた入力を用意し、修正前と修正後のディスパッチへ同じ行を流しました。結果が次の表です。
発火したイベント 修正前 修正後
MessageStart1 1
TextDelta1 1
Failed(event: error)0 1
Usage(message_delta)1(MessageStop 名義) 1
Done(message_stop)0 1
Done が 0 件というのは、UI から見れば「終わったのに、終わったと教えてもらえない」状態です。Failed が 0 件のほうはもっと厄介で、overloaded_error で打ち切られた途中までの回答が、完成品としてそのまま履歴に残ります。HTTP ステータスは 200 のままなので、try/catch にも引っかかりません。ユーザーには「文章が途中で終わる不思議なアシスタント」に見えて、こちらのログには何も残らない。私が最初に受け取った二件目の報告は、これでした。
直した三点
Step 2 のコードにはすでに反映してありますが、変更の要点は三つです。
第一に、終端の判定を message_stop に変えました。[DONE] を待つ分岐は削除します。
第二に、event: error を StreamEvent.Failed として上に投げるようにしました。握り潰さないことが目的です。
第三に、content_block_delta を delta.type == "text_delta" で絞りました。拡張思考を有効にすると thinking_delta が同じ content_block_delta として流れてきます。型で絞っていないと、モデルの検討過程が本文に混ざります。ツール利用時の input_json_delta も同様です。
退行を防ぐテスト
この手の不具合は、直した本人以外には見えません。固定した SSE を流すテストを commonTest に置いておくと、実機もネットワークも要らずに守れます。parseStreamEvent は private から internal へ変更しておきます。
// shared/src/commonTest/kotlin/com/example/ai/StreamTerminationTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
private val SSE_FIXTURE = """
event: message_start
data: {"type":"message_start","message":{"usage":{"input_tokens":25,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: ping
data: {"type":"ping"}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
event: error
data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":15}}
event: message_stop
data: {"type":"message_stop"}
""" . trimIndent ()
class StreamTerminationTest {
private val client = ClaudeClient (apiKey = "test-key" )
private fun dispatch (sse: String ): List < StreamEvent > =
sse. lineSequence ()
. filter { it. startsWith ( "data: " ) }
. mapNotNull { client. parseStreamEvent (it. removePrefix ( "data: " )) }
. toList ()
@Test
fun `message_stop で Done が 1 回だけ発火する` () {
val events = dispatch (SSE_FIXTURE)
assertEquals ( 1 , events. count { it is StreamEvent.Done })
}
@Test
fun `ストリーム途中の error を握り潰さない` () {
val failed = dispatch (SSE_FIXTURE). filterIsInstance < StreamEvent . Failed >()
assertEquals ( 1 , failed.size)
assertEquals ( "overloaded_error" , failed. first ().type)
}
@Test
fun `thinking_delta を本文テキストに混ぜない` () {
val line = """data: {"type":"content_block_delta","index":0,""" +
""""delta":{"type":"thinking_delta","thinking":"内部の検討"}}"""
assertTrue ( dispatch (line). isEmpty ())
}
}
三本目のテストは、拡張思考をまだ使っていない段階でも入れておく価値があると考えています。将来 thinking を有効にしたときに壊れる場所を、あらかじめ固定しておけるためです。
よくある間違いと落とし穴
① iOSでFlowのcollectがUIスレッドをブロックする
Kotlinのコルーチンスコープをios側で作成するとき、MainScope()を使うとUI更新は正しく行われますが、重い処理をDispatchers.Mainで実行すると画面がフリーズします。
// ❌ 間違い: メインスレッドでAPIコールを直接実行
coroutineScope. launch (Dispatchers.Main) {
claudeClient. sendMessage (messages) // メインスレッドがブロックされます
}
// ✅ 正解: I/O処理はDefaultで行い、UI更新だけMainに切り替えます
coroutineScope. launch (Dispatchers.Default) {
val result = claudeClient. sendMessage (messages)
withContext (Dispatchers.Main) {
_uiState. update { .. . }
}
}
② ストリーミング中のキャンセルが正しく動かない
// ❌ 間違い: フラグでFlowを止めようとしてもcollect自体は継続します
var isActive = true
claudeClient. sendMessageStreaming ( .. .). collect { event ->
if ( ! isActive) return @collect // 次のemitが来るまで止まりません
}
// ✅ 正解: コルーチンのキャンセルを使います
val job = coroutineScope. launch {
claudeClient. sendMessageStreaming ( .. .). collect { event ->
// job.cancel()が呼ばれると自動停止します
_uiState. update { .. . }
}
}
// 停止ボタンから
job. cancel ()
③ kotlinx.serializationとignoreUnknownKeys
Claude APIは継続的に新フィールドをレスポンスに追加します。ignoreUnknownKeys = trueを設定しないと、APIアップデート時にアプリがクラッシュします。
// ❌ 間違い: デフォルト設定(新フィールドが来るとクラッシュ)
val json = Json {}
// ✅ 正解: 未知のフィールドを無視します
val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
④ 長い会話でのContext Window超過
会話が長くなるとAPI呼び出しのトークン数が増え、コストとレイテンシが上がります。本番では会話履歴のトリミングが必要です。Step 6のtrimmedHistory()がその実装です。無制限に履歴を保持すると、長時間使うユーザーほどレスポンスが遅くなるという逆説的な問題が起きます。
⑤ iOS向けフレームワークのSwift名前空間衝突
KMPのKotlinクラス名がSwiftの標準ライブラリと衝突することがあります。例えばResultクラスは両方に存在します。
// ❌ 危険: Resultという名前はSwiftに既存
class Result < T >( val value : T )
// ✅ 安全: プレフィックスを付けて衝突を回避します
class ClaudeResult < T >( val value : T )
公式ドキュメントには書かれていない KMP × Claude API の運用知見
ここからは、KMP の入門記事やリリースノートに書かれていないけれど、本番運用で必ず一度はぶつかる挙動を整理します。半年ほどアプリに組み込んで気づいた事実のうち、特に「これは事前に知っていたら時間を返してほしかった」というものを 5 つに絞りました。
① expect/actual の関数シグネチャは 1 文字違っても気づきにくい
expect fun foo(s: String?): String と actual fun foo(s: String): String(? の有無)が混入していても、IDE がエラーを出さず、リリースビルドだけ落ちるケースに遭遇しました。Kotlin 2.0.20 の時点では Linter が見逃すパターンが残っているため、expect 側を変更したら必ず androidMain / iosMain 双方の actual を grep して整合性を取る運用に切り替えました。
// expect (commonMain)
expect fun secureStore (key: String , value : String ): Result < Unit >
// actual (androidMain)
actual fun secureStore (key: String , value : String ): Result < Unit > = .. .
// actual (iosMain) — 引数を 1 つ忘れた状態でもデバッグでは気付きにくい
actual fun secureStore (key: String ): Result < Unit > = .. . // ❌ ビルドは通ることがあります
② iOS の Kotlin/Native は GC のチューニングが必要
Kotlin 1.9 以降で新メモリマネージャ(New Memory Manager)がデフォルトになりましたが、gradle.properties に kotlin.native.binary.gc=cms を入れる前と後では、ストリーミング応答中の一時メモリ使用量が約 30% 違いました。私の壁紙アプリにアシスタント機能を追加した直後、iPhone SE 第 2 世代でメモリ警告が増えたのはこれが原因でした。
# gradle.properties に追加
kotlin.native.binary.gc =cms
kotlin.native.binary.appStateTracking =enabled
③ Ktor の HttpClient インスタンスはアプリ内で 1 つに統一する
KMP の expect/actual で HttpClient をプラットフォーム別に生成しがちですが、Android と iOS でそれぞれシングルトン化すると、TLS セッションの再利用率が約 40% 向上しました。Claude API の messages エンドポイントは平均 2 〜 8 秒のレスポンスなので、TLS ハンドシェイクのコストが体感に直結します。
④ kotlinx-coroutines のバージョン揃え
commonMain と androidMain / iosMain で異なる kotlinx-coroutines-core バージョンを引き込んでしまうと、Dispatchers.IO の挙動がプラットフォーム間で 100 〜 200ms ずれる現象が起きました。Gradle の dependencyResolution で明示的にバージョンを固定し、./gradlew dependencies で重複を確認するのが安全です。
⑤ Claude API の 429 Too Many Requests は HTTP リトライだけでは足りない
Claude API のレート制限は、HTTP ステータスだけでなく anthropic-ratelimit-tokens-remaining ヘッダで現在の残量が分かります。私はリトライポリシーに「残量 1000 未満なら指数バックオフではなく Retry-After ヘッダの値を尊重」というロジックを追加し、429 後の連鎖失敗率が約 15% から 2% 未満に下がりました。
suspend fun handleRateLimit (response: HttpResponse ): Long {
val remaining = response.headers[ "anthropic-ratelimit-tokens-remaining" ]?. toLongOrNull ()
val retryAfter = response.headers[ "retry-after" ]?. toLongOrNull ()
return when {
remaining != null && remaining < 1000 -> (retryAfter ?: 60 ) * 1000L
retryAfter != null -> retryAfter * 1000L
else -> 2000L // フォールバック
}
}
個人アプリ事業から見える KMP × Claude API の設計判断
私が運営しているアプリは、収益の中心が AdMob の広告です。そこに Claude API を足すと、技術より先に事業側の判断が問われます。AI 機能を「ユーザーに価値を届けながら、サーバーコストで赤字にしない」着地点へ寄せていった実体験を書いておきます。
共通化率は 80% を目安に上げる
Android 単独 / iOS 単独で書く場合と比べて、KMP で commonMain にロジックを集中させると、機能追加 1 件あたりの平均工数は約 60% に減りました。逆に「プラットフォーム固有 UI が絡む」「OS の権限ダイアログを跨ぐ」場合は expect/actual を使い分けたほうが速く、共通化率は 80% 前後に落ち着くのが私の感覚です。100% を目指すと、expect の宣言だけが肥大化して読めなくなります。
eCPM とトークンコストのバランス
AdMob のリワード動画 1 視聴の eCPM が日本市場で平均 $8 前後、AI アシスタント 1 セッションが Claude Sonnet 4.6 で約 $0.01(平均 1,500 入力トークン / 800 出力トークン換算)。リワード視聴 1 回で AI セッション 800 回程度を賄える計算です。ARPU を下げないために「無料ユーザーには 1 日 5 セッションまで」という上限を設けるアプリ設計に落ち着きました。Stripe の subscription で Premium プランを用意し、上限解除を ¥580/月 で提供すると、課金率が約 0.6 % から 1.2% へほぼ倍増した実測値です。
コールドスタートの体感を犠牲にしない
Claude API の初回呼び出しは 1.5 〜 3 秒かかります。アプリ起動時に「裏で 1 回ダミーリクエストを送る」プリウォーム戦略を入れたところ、ユーザーがアシスタントを開いた瞬間のレスポンス体感が約 40% 短くなりました。AdMob の起動広告と同じタイミングで走らせれば、ユーザーは待ち時間を意識しません。
// Application 起動時に呼ぶ
fun prewarmClaude () {
coroutineScope. launch (Dispatchers.IO) {
runCatching {
claudeClient. sendMessage (
listOf ( Message ( "user" , "ping" )),
maxTokens = 1 ,
)
}
}
}
Crashlytics / Firebase との接続
iOS の壁紙アプリでは Crashlytics で Claude_API_429・Claude_API_500・Claude_API_Timeout といったカスタムエラーキーを設定し、リリース後 48 時間以内に異常率をダッシュボードで監視しています。KMP の commonMain から expect fun logError(name: String, params: Map<String, String>) を actual で各プラットフォームの Crashlytics SDK にマップすると、共通コードからログを統一できます。
本番リリース直前の実装チェックリスト
ここまで実装してきた内容を、リリース直前に確認するチェックリストにまとめます。私自身、過去に「テストでは通るのに、本番で落ちる」事故を何度か出した経験があるので、自分用のリストとして手元に置いています。
APIキー保護 : commonMain に直書きしていない。Android は EncryptedSharedPreferences、iOS は Keychain Services に置いている
expect/actual 整合性 : 各 expect 宣言について、androidMain / iosMain の actual を grep して関数シグネチャの差異がないか確認
ignoreUnknownKeys = true : kotlinx.serialization の Json インスタンスに必ず設定。Claude API は新フィールドを追加する可能性あり
タイムアウト設定 : ストリーミング応答は最大 60 秒に延長(requestTimeout = 60_000、socketTimeout = 60_000)
指数バックオフ : 5xx エラーはリトライ最大 3 回、429 は Retry-After ヘッダを尊重
コルーチンキャンセル : ユーザーが停止ボタンを押したら Job.cancel() で確実に止まる
メモリリーク : viewModelScope / MainScope() を適切に使い分けし、画面破棄時にコルーチンを止める
テストカバレッジ : commonTest でストリーミング・リトライ・トリミングロジックを 80% 以上カバー
オフライン挙動 : ネットワーク切断時にアプリがクラッシュせず、エラー UI に切り替わる
回線品質 : 3G エミュレータでもタイムアウトせず、ストリーミングのチャンクが表示される
Crashlytics 連携 : Claude_API_* カスタムエラーキーで本番異常率を追跡できる
会話履歴のトリミング : コンテキストウィンドウ超過を起こさないよう、Step 6 の trimmedHistory() が動いている
iOS フレームワーク名前空間 : Result 等の標準名と衝突するクラス名がない(ClaudeResult のようなプレフィックス付与済み)
AdMob / 課金との干渉 : AI アシスタント呼び出し中にリワード広告が再生されてもコルーチンが落ちない
私は壁紙アプリにアシスタント機能を出す前、このうち 4・10・14 を見落としており、リリース後 24 時間以内にホットフィックスを出すことになりました。個人開発の利点は素早くデプロイできることですが、欠点はテスト網が薄くなりがちなこと です。リリース前にこのリストを通すだけで、ホットフィックスの本数は明らかに減りました。
最後に
この記事で扱った不具合のうち、コードを眺めていて見つかったものは一つもありませんでした。[DONE] の件も expect/actual のシグネチャずれも、ビルドは通り、テストも通り、画面上は正しく見えていました。KMP で時間を取られるのは「両方のプラットフォームで同じように壊れてくれない」場所よりも、「壊れているのに壊れて見えない」場所のほうだと感じています。
すでに Claude API のストリーミングを組み込んだコードをお持ちでしたら、まず [DONE] で grep してみてください。ヒットするようでしたら、この記事の SSE フィクスチャをそのまま commonTest に置くところから始めるのが近道です。手元では、実機もネットワークも使わずに三つの不具合をまとめて固定できました。
Swift Concurrency との統合をさらに進めたい場合は、SKIE(Swift Kotlin Interface Enhancer) が Kotlin の Flow を AsyncSequence へ自動変換してくれます。Step 7 で書いた FlowCollector まわりのボイラープレートは、ほとんど消えます。KMP の全体像は Kotlin 公式ドキュメント 、Ktor Client の詳細は Ktor 公式ガイド が確かです。
私自身まだ試行錯誤の途中にいます。ここまでお読みいただき、ありがとうございました。