Claude Sonnet で Android アプリに Compose UI のページを追加する。

下準備として、Github Pro の課金が必要です。月10ドルなので、本家 Claude Code よりも安め(月20ドルだったか?)でいけます。

GitHub のプラン – GitHub Docs https://docs.github.com/ja/get-started/learning-about-github/githubs-plans

Android Studio から直接 Claude Sonnet は使えない(と思う)ので、vscode 上でコードの変更を行います。Github Pro を入れておくと、Copilot のアイコンが使えるので、いつものチャット(Ask)からエージェント(Agent)に切り替えます。

  • Ask の場合は、チャット上でコードの提案をしてくれます
  • Agent の場合は、要望や質問を Copilot 自身がファイルを修正してくれます

Agent のほうがファイルを直接操作してくれるので手間は少ないです。が、あらぬところを変えることがあるので、そういうときは Ask モードに切り替えるか、git でブランチを作っておいて元のファイルに戻します。元のファイルに戻したときは

元のファイルに戻しました

と Copilot に知らせてあげると話がスムーズになります。Claude Sonnet があれこれと調べ始めるので。

コード生成のスピードは直接 Claude Code に課金したほうが早いのですが、Claude Code だと結構なお金が掛かる(Maxだと特に)のと、API を使うときの制限が厳しいです。仕事的に 2,3 時間使ってしまうと制限に達してしまいます。なぜかわかりませんが、Copilot + Claude Sonnet の組み合わせだと、この API 呼び出しが無制限になっています。なので、あまり生成スピードを気にせずにだらだらやる場合は Sonnet のほうがお得です。多分、Claude Code のほうが色々と便利なコマンドがあるんでしょうが、いまのところ私は Claude Sonnet で十分な感じです。

新しい Compose ページを追加する

Android の Jetpack Compose は、React の JSX 形式のようにコード内で UI を記述することができます。まあ、機能的にはそれだけではないのですが、この「コードに記述する」というスタイルが、AI エージェントと相性がいいです。

従来の Android UI の場合は Android Stdio を立ち上げてデザイナを使って画面を構成することが多く、その設定は *.xml ファイルに記述されます。直接 *.xml ファイルを編集することもあるのですが、結構面倒で、さらに言えば AI エージェントのこれをやって貰うと大変なことになります。どういう大変なことになるかというと、それは各自に試してみてください。端的に言えば、完成しません、ってだけなんですが。

バージョンを示すページを作ってみましょう。

バージョンを表示する AboutActivity を作成して。

いろいろ項目を入れる必要があると感じますが、いいえ、そんなことはありません。最初のテンプレートだけを作りたいので、一行だけプロンプトで指示すれば ok です。

完全に余計なお世話的な感じで作ってくれますが、これを使います。

画面を表示するに AndroidManifest.xml に追加しておきます。これは最初に指示をすると追加してくれたり、追加してくれなかったりします。

AboutActivity を AndroidManifest.xml に追加して。

設定した後に Android Studio でビルドをして確認しておきます。

Compose UI で作成したときに、プレビューが出ないのが残念なところなのですが、これは放置します。手書きで書くとプレビューが表示されるので、おそらく Claude Sonnet の生成するコードのせいです。

package jp.mamori_i.app.screen.ui

import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import jp.mamori_i.app.ui.theme.AppTheme
import java.text.SimpleDateFormat
import java.util.*

class AboutActivity : ComponentActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        enableEdgeToEdge()
        setContent {
            AppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    AboutScreen(
                        onNavigateBack = {
                            finish()
                        },
                        context = this@AboutActivity
                    )
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
    onNavigateBack: () -> Unit = {},
    context: ComponentActivity? = null
) {
    // バージョン情報を取得
    val versionInfo = remember {
        context?.let { ctx ->
            try {
                val packageInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
                mapOf(
                    "appName" to (ctx.applicationInfo.loadLabel(ctx.packageManager).toString()),
                    "versionName" to packageInfo.versionName,
                    "versionCode" to packageInfo.longVersionCode.toString(),
                    "packageName" to packageInfo.packageName,
                    "buildTime" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(packageInfo.lastUpdateTime))
                )
            } catch (e: PackageManager.NameNotFoundException) {
                mapOf(
                    "appName" to "FolkBears",
                    "versionName" to "1.0.0",
                    "versionCode" to "1",
                    "packageName" to "jp.mamori_i.app",
                    "buildTime" to "Unknown"
                )
            }
        } ?: mapOf(
            "appName" to "FolkBears",
            "versionName" to "1.0.0",
            "versionCode" to "1",
            "packageName" to "jp.mamori_i.app",
            "buildTime" to "Unknown"
        )
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("アプリについて") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = "戻る"
                        )
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp)
                .verticalScroll(rememberScrollState()),
            verticalArrangement = Arrangement.spacedBy(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Spacer(modifier = Modifier.height(32.dp))
            
            // アプリアイコン(代替として情報アイコンを使用)
            Card(
                modifier = Modifier.size(80.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.primary
                )
            ) {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Icon(
                        imageVector = Icons.Default.Info,
                        contentDescription = null,
                        modifier = Modifier.size(40.dp),
                        tint = MaterialTheme.colorScheme.onPrimary
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            // アプリ名
            Text(
                text = versionInfo["appName"] ?: "FolkBears",
                style = MaterialTheme.typography.headlineMedium,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.Center
            )
            
            // バージョン情報
            Text(
                text = "バージョン ${versionInfo["versionName"]} (${versionInfo["versionCode"]})",
                style = MaterialTheme.typography.titleMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                textAlign = TextAlign.Center
            )
            
            Spacer(modifier = Modifier.height(24.dp))
            
            // 詳細情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "アプリケーション情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    InfoRow("アプリ名", versionInfo["appName"] ?: "Unknown")
                    InfoRow("バージョン", versionInfo["versionName"] ?: "Unknown")
                    InfoRow("ビルド番号", versionInfo["versionCode"] ?: "Unknown")
                    InfoRow("パッケージ名", versionInfo["packageName"] ?: "Unknown")
                    InfoRow("ビルド日時", versionInfo["buildTime"] ?: "Unknown")
                }
            }
            
            // システム情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "システム情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    InfoRow("Android バージョン", android.os.Build.VERSION.RELEASE)
                    InfoRow("API レベル", android.os.Build.VERSION.SDK_INT.toString())
                    InfoRow("デバイス", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
                    InfoRow("CPU アーキテクチャ", System.getProperty("os.arch") ?: "Unknown")
                }
            }
            
            // 著作権情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "著作権情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    Text(
                        text = "© 2024 FolkBears Group\nAll rights reserved.",
                        style = MaterialTheme.typography.bodyMedium,
                        textAlign = TextAlign.Center,
                        modifier = Modifier.fillMaxWidth()
                    )
                    
                    Text(
                        text = "このアプリケーションは接触確認システムとして開発されました。",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        textAlign = TextAlign.Center,
                        modifier = Modifier.fillMaxWidth()
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(32.dp))
        }
    }
}

@Composable
fun InfoRow(label: String, value: String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(
            text = "$label:",
            style = MaterialTheme.typography.bodyMedium,
            fontWeight = FontWeight.Medium,
            modifier = Modifier.weight(1f)
        )
        Text(
            text = value,
            style = MaterialTheme.typography.bodyMedium,
            fontFamily = FontFamily.Monospace,
            color = MaterialTheme.colorScheme.primary,
            modifier = Modifier.weight(1f),
            textAlign = TextAlign.End
        )
    }
}

@Preview(showBackground = true)
@Composable
fun AboutScreenPreview() {
    AppTheme {
        AboutScreen()
    }
}

まあ、それでも AI エージェントを使ってバイブコード(Vibe Coding)する分には十分な品質を保っています。About ページならばセキュリティがどうという話もないし特殊なロジックを使っているわけではありません。単にアプリの製品情報とかを並べたいだけです。

従来の方式で言えば Android Studio や Visual Studio が出してくれるテンプレートを使えばいいのですが、 そこすら面倒くさいし、こうやって AI エージェントが作りやすいように画面設計や変数設定をしてしまったほうが後々楽でしょう。うまくいかなければ、もう一度いちから AI エージェントに作って貰えばよいです。

エミュレータで実行するとこんな感じです。

既存の Activity を編集してもらう

About 画面ができたので、いくつか編集をしてみましょう。

UI を作成している AboutScreen の中身を手作業で変更してもよいのですが、AI エージェントを使っても追加や削除ができます。手作業だとレイアウトが崩れたりするので、情報を与えて AI エージェントに修正して貰ったほうが作業が早く済みます。複雑なロジックの場合は面倒なのですが、ちょっとした文言を追加するのはプロンプトだけで結構いけます。

バージョン情報の前に

- アプリの目的:これは接触確認アプリを更に進化させて、実験的に接触記録をトレースできるものです。
- アプリの外報 : 複数の端末で GATT/iBeacon 方式で送受信することにより、相互の距離を記録していきます。接触回数や時刻などを研究データとして役立てるために、指定のサーバーに送信します。

の文言を追加して。

コードをざっと眺めて、うまくいかないようであれば git で元に戻せば ok です。あるいは、コードの変更がしめされたときに「取り消し」します。

このあたりの修正サイクルを Claude Code の設計書(claude.md)に最初に練り込んでいくのか、それとも後からプロンプトを使って修正していくのかと議論の分かれるところでしょうが、私としては後者である「ペアプロ」方式のほうが絶対に楽です。最初の設計書の場合は、まあ、環境づくりとかひな形の作成に使うとよいでしょう。夜間バッチ的に動かせば人手がいれなくなるので結構いいと思います。が、その場合は、Claude Code にバッチファイルを作って貰ったほうがいいんじゃないでしょうか…とは思いますが、開発プロジェクトのスタイルによってそれぞれ。要は、開発者(=自分)が疲弊しない方法を選んでください。あと、やってみて楽しいほうがいいです。

カテゴリー: 開発 パーマリンク