0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android Studio完全入門 - ToDoアプリで学ぶKotlin/Compose/MVVM/CI/CD

Posted at

📌 はじめに

本記事では、Android Studioの使い方から、モダンなAndroidアプリ開発の実践まで、段階的に学べる完全ガイドを提供します。

対象読者:

  • Android開発を始めたい初心者
  • MVVM/Clean Architectureを学びたい中級者
  • 本番運用レベルの実装を目指す上級者

この記事で作成するアプリ:
シンプルなToDoアプリを通じて、以下の技術を習得します。

技術スタック:

  • 言語: Kotlin
  • UI: Jetpack Compose
  • アーキテクチャ: MVVM → Clean Architecture
  • DB: Room Database
  • DI: Hilt
  • 非同期処理: Coroutines + Flow
  • CI/CD: GitHub Actions

記事の読み方:

  • 初心者の方: Part 1〜2を中心に、手を動かしながら進めてください
  • 中級者の方: Part 3から読み始め、MVVMとRoomの実装を学んでください
  • 上級者の方: Part 4〜5でClean ArchitectureとCI/CDを習得してください

注意事項:

  • 本記事のコードは学習用です。本番環境では適切なエラーハンドリングやセキュリティ対策を追加してください
  • バージョン情報は2024年11月時点のものです。最新情報は公式ドキュメントをご確認ください
  • XMLファイル内の[Android名前空間URL][Tools名前空間URL]は、実際にはhttp://schemas.android.com/apk/res/androidhttp://schemas.android.com/toolsを使用します(Qiita投稿制限のため省略表記)

⏱️ 全体の学習時間: 約24〜33時間(初心者が最初から最後まで学習する場合)


目次

Part 1: 環境構築編(初心者向け)

  1. Android Studioのインストール
  2. 初期設定とSDKセットアップ
  3. エミュレータの設定
  4. Android Studioの画面構成
  5. 基本操作とショートカット

Part 2: プロジェクト作成とUI基礎編(初心者〜中級者向け)

  1. プロジェクトの作成
  2. プロジェクト構造の理解
  3. Jetpack Compose基礎
  4. 基本的なUI作成
  5. レイアウトプレビューとデバッグ

Part 3: MVVM実装編(中級者向け)

  1. MVVMアーキテクチャの理解
  2. ViewModelの実装
  3. Room Databaseのセットアップ
  4. Repository層の実装
  5. Hiltによる依存性注入
  6. Coroutines/Flowの活用

Part 4: Clean Architecture実装編(上級者向け)

  1. Clean Architectureの理解
  2. レイヤー分離の実装
  3. UseCaseの実装
  4. テスト戦略
  5. ユニットテスト
  6. UIテスト

Part 5: CI/CD・リリース編(上級者向け)

  1. GitHub Actionsの設定
  2. 自動ビルド・テスト
  3. コード品質チェック
  4. 署名とリリースビルド
  5. 難読化(ProGuard/R8)

Part 1: 環境構築編(初心者向け)

⏱️ 学習時間の目安: 2〜3時間

1.1 Android Studioのインストール

1.1.1 システム要件

Windows:

  • OS: Windows 10/11 (64-bit)
  • RAM: 8GB以上(推奨16GB)
  • ディスク空き容量: 8GB以上
  • 画面解像度: 1280 x 800以上

macOS:

  • OS: macOS 10.14以上
  • RAM: 8GB以上(推奨16GB)
  • ディスク空き容量: 8GB以上

Linux:

  • OS: Ubuntu 18.04以上、Debian 10以上等
  • RAM: 8GB以上(推奨16GB)
  • ディスク空き容量: 8GB以上

1.1.2 インストール手順

Step 1: 公式サイトからダウンロード

https://developer.android.com/studio

Step 2: インストーラーの実行

Windows:

  1. ダウンロードした.exeファイルを実行
  2. インストールウィザードに従って進める
  3. 「Android Virtual Device」にチェックを入れる
  4. インストール先を指定(デフォルト推奨)
  5. インストール完了後、「Start Android Studio」をチェックして起動

macOS:

  1. ダウンロードした.dmgファイルを開く
  2. Android Studioアイコンをアプリケーションフォルダにドラッグ
  3. アプリケーションフォルダからAndroid Studioを起動

Linux:

# ダウンロードしたファイルを解凍
sudo tar -xzf android-studio-*.tar.gz -C /opt/

# Android Studioを起動
cd /opt/android-studio/bin
./studio.sh

1.1.3 初回起動時の設定

Step 1: Setup Wizardの開始

  • 「Do not import settings」を選択(初回の場合)
  • 「OK」をクリック

Step 2: データ共有の選択

  • 使用状況データの送信可否を選択
  • 「Next」をクリック

Step 3: インストールタイプの選択

  • 「Standard」を選択(推奨)
  • 「Next」をクリック

Step 4: UIテーマの選択

  • 「Light」または「Darcula」(ダークテーマ)を選択
  • 「Next」をクリック

Step 5: SDK Componentsの確認

  • Android SDK、Platform-Tools、エミュレータ等が表示される
  • 「Next」をクリック

Step 6: ライセンスの同意

  • すべてのライセンスに同意
  • 「Finish」をクリック

Step 7: コンポーネントのダウンロード

  • SDKやエミュレータのダウンロードが開始
  • 完了まで待機(数分〜数十分)

1.2 初期設定とSDKセットアップ

1.2.1 SDK Managerの起動

方法1: メニューから起動

Tools → SDK Manager

方法2: ツールバーから起動

  • ツールバーの「SDK Manager」アイコンをクリック

1.2.2 SDK Platformsのインストール

推奨設定:

  1. SDK Managerを開く

  2. 「SDK Platforms」タブを選択

  3. 以下をチェック:

    • ✅ Android 14.0 (API 34) ← 最新版
    • ✅ Android 13.0 (API 33)
    • ✅ Android 12.0 (API 31)
    • ✅ Android 11.0 (API 30)
    • 右下の「Show Package Details」をチェック
    • 各APIレベルで以下を選択:
      • ✅ Android SDK Platform XX
      • ✅ Sources for Android XX
      • ✅ Google APIs Intel x86 Atom System Image(エミュレータ用)
  4. 「Apply」をクリック

  5. ライセンス同意後、「OK」をクリック

  6. ダウンロード完了を待つ

1.2.3 SDK Toolsのインストール

  1. SDK Managerで「SDK Tools」タブを選択

  2. 「Show Package Details」をチェック

  3. 以下をチェック:

    • ✅ Android SDK Build-Tools(最新版)
    • ✅ Android Emulator
    • ✅ Android SDK Platform-Tools
    • ✅ Android SDK Tools
    • ✅ Intel x86 Emulator Accelerator (HAXM installer) ※Windows/Mac
    • ✅ Google Play services
    • ✅ Google USB Driver ※Windows
  4. 「Apply」→「OK」をクリック

  5. インストール完了を待つ

1.2.4 環境変数の設定(オプション)

コマンドラインからADBやSDKツールを使う場合に設定します。

Windows:

# ANDROID_HOME環境変数の設定
setx ANDROID_HOME "C:\Users\<ユーザー名>\AppData\Local\Android\Sdk"

# Pathに追加
setx PATH "%PATH%;%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\tools"

macOS/Linux:

# ~/.bash_profile または ~/.zshrc に追加
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/tools
export PATH=$PATH:$ANDROID_HOME/tools/bin

# 設定を反映
source ~/.bash_profile
# または
source ~/.zshrc

確認:

# ADBバージョン確認
adb version

# SDKパス確認
echo $ANDROID_HOME

1.3 エミュレータの設定

1.3.1 AVD Managerの起動

方法1: メニューから起動

Tools → Device Manager

方法2: ツールバーから起動

  • ツールバーの「Device Manager」アイコンをクリック

1.3.2 仮想デバイスの作成

Step 1: デバイスの選択

  1. 「Create Device」をクリック
  2. カテゴリから「Phone」を選択
  3. デバイスを選択(推奨: Pixel 6)
    • 画面サイズ、解像度、密度が表示される
  4. 「Next」をクリック

Step 2: システムイメージの選択

  1. 「Recommended」タブから選択(推奨)
  2. API Level 34 (Android 14.0)を選択
    • 「Download」をクリックしてダウンロード(初回のみ)
  3. 「Next」をクリック

Step 3: AVDの設定

  1. AVD Name: 例「Pixel_6_API_34」
  2. Startup orientation: Portrait(縦向き)
  3. 「Show Advanced Settings」をクリック(詳細設定の場合)
    • RAM: 2048MB以上推奨
    • VM heap: 512MB
    • Internal Storage: 2048MB以上
    • SD card: 512MB(必要に応じて)
    • Graphics: Automatic(推奨)、またはHardware
  4. 「Finish」をクリック

1.3.3 エミュレータの起動

方法1: Device Managerから起動

  1. Device Managerを開く
  2. 作成したAVDの「▶」(再生)ボタンをクリック

方法2: ツールバーから起動

  1. ツールバーのデバイス選択ドロップダウンから選択
  2. 「▶」(Run)ボタンをクリック

起動確認:

  • エミュレータウィンドウが開く
  • Androidのホーム画面が表示される
  • 初回起動は数分かかる場合があります

1.3.4 エミュレータの基本操作

画面右側のツールバー:

  • 電源ボタン: 画面オン/オフ、長押しで電源メニュー
  • 音量up/down: 音量調整
  • 回転: 画面の縦横切り替え
  • 戻る: バックボタン
  • ホーム: ホーム画面に戻る
  • マルチタスク: アプリ履歴表示

画面下部のツールバー:

  • Extended controls: 詳細設定パネルを開く
    • Location: GPS位置情報のシミュレート
    • Cellular: ネットワーク状態のシミュレート
    • Battery: バッテリー状態のシミュレート
    • Phone: 電話着信のシミュレート
    • Camera: カメラ設定
    • など

便利なショートカット:

  • Ctrl + M (Mac: Cmd + M): メニューを開く
  • Ctrl + →/←: 画面回転
  • Ctrl + Shift + P: 電源ボタン

1.3.5 エミュレータのパフォーマンス最適化

Intel HAXM/AMD Hypervisor有効化 (Windows/Mac):

  • Windowsの場合、BIOS設定でVT-x/AMD-Vを有効化
  • Macは自動的に最適化

Graphics設定:

  1. AVD設定を開く(Device Managerで「編集」アイコン)
  2. 「Show Advanced Settings」
  3. Graphics: 「Hardware - GLES 2.0」を選択

スナップショット機能:

  • Quick Boot機能で起動時間を短縮
  • デフォルトで有効

1.4 Android Studioの画面構成

Android Studioの主要な画面要素を理解しましょう。

1.4.1 メインウィンドウの構成

┌─────────────────────────────────────────────────────────────┐
│ メニューバー (File, Edit, View, Navigate, ...)              │
├─────────────────────────────────────────────────────────────┤
│ ツールバー (Run, Debug, SDK Manager, Device Manager, ...)   │
├──────────┬──────────────────────────────────┬───────────────┤
│          │                                  │               │
│ Project  │      エディタエリア               │  Structure   │
│ ツール   │                                  │  Gradle      │
│ ウィンドウ│    (コード編集画面)               │  など        │
│          │                                  │               │
│          │                                  │               │
├──────────┴──────────────────────────────────┴───────────────┤
│ ステータスバー (Build情報、エラー/警告、Git情報など)          │
└─────────────────────────────────────────────────────────────┘

1.4.2 主要な画面要素

1. メニューバー:

  • File: プロジェクト作成、設定など
  • Edit: 編集操作、検索・置換など
  • View: 画面表示の切り替え
  • Navigate: コード内の移動
  • Code: コード生成、リファクタリング
  • Refactor: リファクタリング機能
  • Build: ビルド実行
  • Run: アプリの実行
  • Tools: 各種ツール(AVD Manager、SDK Managerなど)
  • VCS: バージョン管理(Git等)

2. ツールバー:
主要なアクションへのクイックアクセス

  • Run (▶): アプリの実行
  • Debug (🐛): デバッグモードで実行
  • Stop (⬛): 実行中のアプリを停止
  • デバイス選択ドロップダウン
  • SDK Manager
  • Device Manager
  • Sync Project with Gradle Files

3. Project Tool Window(左側):
プロジェクトのファイル構造を表示

  • Android View: Androidプロジェクト構造(推奨)
  • Project View: 実際のファイルシステム構造
  • Packages View: パッケージ別表示
  • その他: Problems, Build Variants など

表示切り替え:

View → Tool Windows → Project
ショートカット: Alt + 1 (Mac: Cmd + 1)

4. Editor Area(中央):
コードやレイアウトファイルの編集エリア

  • タブで複数ファイルを切り替え
  • 分割表示可能(右クリック → Split Vertically/Horizontally)
  • Composeプレビュー表示(Jetpack Compose使用時)

5. Structure Tool Window(右側):
現在開いているファイルの構造を表示

  • クラスのメソッド一覧
  • XMLの要素一覧
  • クリックで該当箇所にジャンプ

表示切り替え:

View → Tool Windows → Structure
ショートカット: Alt + 7 (Mac: Cmd + 7)

6. Bottom Tool Windows(下部):
タブで切り替え可能な各種ツール

  • Logcat: ログ表示(重要!)
  • Terminal: コマンドライン
  • Build: ビルド出力
  • Run: 実行結果
  • TODO: TODOコメント一覧
  • Problems: エラー・警告一覧
  • Version Control: Git操作

Logcatの表示:

View → Tool Windows → Logcat
ショートカット: Alt + 6 (Mac: Cmd + 6)

7. ステータスバー(最下部):

  • ビルド状態
  • エラー/警告の数
  • Gitブランチ
  • メモリ使用状況
  • エンコーディング
  • 行番号:列番号

1.4.3 ビューモードの切り替え

Distraction Free Mode(集中モード):

View → Appearance → Enter Distraction Free Mode
  • ツールウィンドウを非表示にして、エディタのみ表示

Presentation Mode(プレゼンモード):

View → Appearance → Enter Presentation Mode
  • フォントサイズを大きくして表示

Full Screen:

View → Appearance → Enter Full Screen
  • フルスクリーン表示

1.5 基本操作とショートカット

1.5.1 必須ショートカット

ファイル操作:

操作 Windows/Linux Mac
ファイル検索 Ctrl + Shift + N Cmd + Shift + O
クラス検索 Ctrl + N Cmd + O
シンボル検索 Ctrl + Alt + Shift + N Cmd + Option + O
最近使用したファイル Ctrl + E Cmd + E
ファイル構造 Ctrl + F12 Cmd + F12

編集操作:

操作 Windows/Linux Mac
コード補完 Ctrl + Space Ctrl + Space
スマート補完 Ctrl + Shift + Space Ctrl + Shift + Space
パラメータ情報 Ctrl + P Cmd + P
クイックドキュメント Ctrl + Q Ctrl + J
定義へジャンプ Ctrl + B または Ctrl + Click Cmd + B
実装へジャンプ Ctrl + Alt + B Cmd + Option + B
使用箇所を検索 Alt + F7 Option + F7
行の複製 Ctrl + D Cmd + D
行の削除 Ctrl + Y Cmd + Backspace
行の移動 Alt + Shift + ↑/↓ Option + Shift + ↑/↓
コメントアウト Ctrl + / Cmd + /
ブロックコメント Ctrl + Shift + / Cmd + Shift + /

リファクタリング:

操作 Windows/Linux Mac
リネーム Shift + F6 Shift + F6
リファクタリングメニュー Ctrl + Alt + Shift + T Ctrl + T
メソッド抽出 Ctrl + Alt + M Cmd + Option + M
変数抽出 Ctrl + Alt + V Cmd + Option + V
インライン化 Ctrl + Alt + N Cmd + Option + N

ナビゲーション:

操作 Windows/Linux Mac
前の場所に戻る Ctrl + Alt + ← Cmd + [
次の場所に進む Ctrl + Alt + → Cmd + ]
どこでも検索 Shift × 2 (Shift2回) Shift × 2
アクション検索 Ctrl + Shift + A Cmd + Shift + A

実行・デバッグ:

操作 Windows/Linux Mac
実行 Shift + F10 Ctrl + R
デバッグ Shift + F9 Ctrl + D
ブレークポイント設定/解除 Ctrl + F8 Cmd + F8
ステップオーバー F8 F8
ステップイン F7 F7
ステップアウト Shift + F8 Shift + F8

ツールウィンドウ:

操作 Windows/Linux Mac
Project Alt + 1 Cmd + 1
Logcat Alt + 6 Cmd + 6
Structure Alt + 7 Cmd + 7
Terminal Alt + F12 Option + F12
全ツールウィンドウ非表示 Ctrl + Shift + F12 Cmd + Shift + F12

1.5.2 便利な機能

1. Live Templates(コードスニペット):

よく使うコードパターンを素早く入力できます。

使い方:

// "logd" と入力して Tab キー
Log.d(TAG, "")

// "fori" と入力して Tab キー
for (i in 0 until ) {
    
}

// "todo" と入力して Tab キー
// TODO: 

主要なLive Templates:

  • logd: Log.d()
  • logi: Log.i()
  • loge: Log.e()
  • toast: Toast.makeText()
  • fori: forループ
  • iter: イテレータのforループ
  • ifn: if null
  • inn: if not null
  • lazy: lazy初期化

カスタムテンプレートの作成:

File → Settings → Editor → Live Templates

2. Postfix Completion:

コードの後ろに特定の文字を入力して変換

// .var と入力
"Hello".var

val name = "Hello"

// .let と入力
user.let

user.let {  }

// .if と入力
condition.if

if (condition) {
    
}

// .return と入力
value.return

return value

3. Multi-Cursor Editing:

複数箇所を同時編集

Alt + J (Mac: Ctrl + G): 次の一致を選択
Alt + Shift + J: 前の選択を解除
Ctrl + Alt + Shift + J: すべての一致を選択

使用例:

// 変数名を一括変更
val oldName = "test"
println(oldName)
log(oldName)

// "oldName" にカーソルを置いて Ctrl + Alt + Shift + J
// すべての "oldName" が選択される
// 一括で "newName" に変更可能

4. Code Generation:

コードの自動生成

Alt + Insert (Mac: Cmd + N)

生成できる内容:

  • Constructor(コンストラクタ)
  • Getter/Setter
  • equals()とhashCode()
  • toString()
  • Override Methods(メソッドのオーバーライド)
  • Implement Methods(インターフェースの実装)

5. Surround With:

コードを特定のブロックで囲む

Ctrl + Alt + T (Mac: Cmd + Option + T)

選択肢:

  • if
  • if/else
  • try/catch
  • try/catch/finally
  • for
  • while
  • runBlocking

6. Optimize Imports:

不要なimportの削除と整理

Ctrl + Alt + O (Mac: Ctrl + Option + O)

7. Reformat Code:

コードの自動整形

Ctrl + Alt + L (Mac: Cmd + Option + L)

8. Inspect Code:

コード品質のチェック

Analyze → Inspect Code
  • 未使用の変数・メソッド
  • 潜在的なバグ
  • パフォーマンス問題
  • コードスタイル違反
  • などを検出

1.5.3 設定のカスタマイズ

Settings/Preferencesを開く:

File → Settings (Mac: Android Studio → Preferences)
ショートカット: Ctrl + Alt + S (Mac: Cmd + ,)

推奨設定:

1. エディタ設定:

Editor → General
  • ✅ Change font size with Ctrl+Mouse Wheel
  • ✅ Show line numbers
  • ✅ Show whitespaces

2. Auto Import設定:

Editor → General → Auto Import
  • ✅ Add unambiguous imports on the fly
  • ✅ Optimize imports on the fly

3. Code Style設定:

Editor → Code Style → Kotlin
  • Scheme: Default または Project(プロジェクト固有)
  • Tabs and Indents: 4 spaces(Kotlinの推奨)

4. キーマップ設定:

Keymap
  • デフォルトのまま、または好みに応じてカスタマイズ
  • VSCode風、Eclipse風なども選択可能

5. プラグイン:

Plugins

推奨プラグイン:

  • Kotlin: デフォルトでインストール済み
  • Rainbow Brackets: 括弧を色分け
  • Key Promoter X: ショートカットを学習
  • GitToolBox: Git拡張機能
  • .ignore: .gitignoreファイルのサポート

💡 初心者の方へ:
最初からすべての設定を完璧にする必要はありません。開発を進めながら、必要に応じて設定をカスタマイズしていきましょう。特に「Auto Import」は有効にしておくと便利です。

1.5.4 Gradleの基本操作

Gradle同期:

ツールバー: "Sync Project with Gradle Files" アイコン
ショートカット: Ctrl + Shift + O (Mac: Cmd + Shift + O)

Gradleタスクの実行:

View → Tool Windows → Gradle

よく使うタスク:

  • build: プロジェクトのビルド
  • clean: ビルド成果物の削除
  • assembleDebug: デバッグAPKの生成
  • assembleRelease: リリースAPKの生成
  • test: ユニットテストの実行
  • connectedAndroidTest: 接続されたデバイスでテスト実行

コマンドラインから実行:

# Windows
gradlew.bat build

# Mac/Linux
./gradlew build

🎉 Part 1完了!
環境構築が完了しました。次は実際にプロジェクトを作成してUIを実装していきます。


Part 2: プロジェクト作成とUI基礎編(初心者〜中級者向け)

⏱️ 学習時間の目安: 4〜6時間

2.1 プロジェクトの作成

2.1.1 新規プロジェクトの作成手順

Step 1: プロジェクトウィザードを開く

File → New → New Project

または、Welcome画面から「New Project」をクリック

Step 2: テンプレートの選択

  1. 「Phone and Tablet」タブを選択
  2. 「Empty Activity」を選択
    • Jetpack Composeを使用する最もシンプルなテンプレート
  3. 「Next」をクリック

Step 3: プロジェクト設定

Name: TodoApp
Package name: com.example.todoapp
Save location: 任意のパス
Language: Kotlin
Minimum SDK: API 24: Android 7.0 (Nougat)
Build configuration language: Kotlin DSL (build.gradle.kts)

各項目の説明:

  • Name: アプリ名(ユーザーに表示される名前)
  • Package name: アプリの一意な識別子(逆ドメイン形式)
    • 形式: com.会社名.アプリ名
    • 例: com.example.todoapp
  • Save location: プロジェクトの保存先
  • Language: Kotlin(推奨) または Java
  • Minimum SDK: サポートする最小Androidバージョン
    • API 24 (Android 7.0): カバー率 95%以上(推奨)
    • API 21 (Android 5.0): カバー率 98%以上
  • Build configuration language:
    • Kotlin DSL: 型安全、コード補完が効く(推奨)
    • Groovy: 従来の記法

Step 4: プロジェクト作成完了

  • 「Finish」をクリック
  • Gradleの同期とインデックス作成が開始
  • 数分待つと完了

2.1.2 プロジェクト作成時のトラブルシューティング

問題1: Gradle同期エラー

エラー: "Failed to sync Gradle project"

解決策:

  1. インターネット接続を確認
  2. Gradleの再同期を試す
    File → Sync Project with Gradle Files
    
  3. Gradleキャッシュのクリア
    File → Invalidate Caches → Invalidate and Restart
    

問題2: SDK not found

エラー: "SDK location not found"

解決策:
local.propertiesファイルにSDKパスを設定

sdk.dir=/Users/<ユーザー名>/Library/Android/sdk

問題3: プロジェクトのインデックス作成が遅い

解決策:

  • 初回は時間がかかるので待つ
  • ウイルス対策ソフトの除外設定を追加
    • Android SDK フォルダ
    • プロジェクトフォルダ

2.2 プロジェクト構造の理解

2.2.1 Android Viewでの構造

Android Studioの「Android」ビューで表示される構造:

TodoApp/
├── app/
│   ├── manifests/
│   │   └── AndroidManifest.xml          # アプリの設定ファイル
│   ├── kotlin+java/
│   │   └── com.example.todoapp/
│   │       └── MainActivity.kt           # メインActivity
│   ├── res/                              # リソースフォルダ
│   │   ├── drawable/                     # 画像リソース
│   │   ├── mipmap/                       # アプリアイコン
│   │   ├── values/                       # 値リソース
│   │   │   ├── colors.xml               # 色定義
│   │   │   ├── strings.xml              # 文字列定義
│   │   │   └── themes.xml               # テーマ定義
│   │   └── xml/
│   └── Gradle Scripts/
│       ├── build.gradle.kts (Project)    # プロジェクトレベルのGradle設定
│       ├── build.gradle.kts (Module: app)# モジュールレベルのGradle設定
│       ├── gradle.properties             # Gradle設定
│       └── settings.gradle.kts           # プロジェクト設定
└── Gradle Scripts/

2.2.2 Project Viewでの実際の構造

実際のファイルシステム構造:

TodoApp/
├── .gradle/                    # Gradleキャッシュ
├── .idea/                      # IDEの設定ファイル
├── app/                        # アプリモジュール
│   ├── build/                  # ビルド成果物
│   ├── libs/                   # 外部ライブラリ
│   ├── src/
│   │   ├── main/
│   │   │   ├── java/           # Kotlinコード
│   │   │   │   └── com/example/todoapp/
│   │   │   │       └── MainActivity.kt
│   │   │   ├── res/            # リソース
│   │   │   └── AndroidManifest.xml
│   │   ├── androidTest/        # Android Instrumentation Test
│   │   └── test/               # Unit Test
│   ├── build.gradle.kts        # モジュールのビルド設定
│   └── proguard-rules.pro      # ProGuard設定
├── gradle/
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── build.gradle.kts            # プロジェクトのビルド設定
├── settings.gradle.kts         # プロジェクト設定
├── gradle.properties           # Gradle設定
├── gradlew                     # Gradleラッパー(Unix)
├── gradlew.bat                 # Gradleラッパー(Windows)
└── local.properties            # ローカル設定(SDKパスなど)

2.2.3 主要ファイルの説明

1. AndroidManifest.xml

アプリの基本情報を定義

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="[Android名前空間URL]"
    xmlns:tools="[Tools名前空間URL]">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.TodoApp"
        tools:targetApi="31">
        
        <!-- メインActivity -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:theme="@style/Theme.TodoApp">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        
    </application>

</manifest>

主要な要素:

  • <application>: アプリ全体の設定
    • android:icon: アプリアイコン
    • android:label: アプリ名
    • android:theme: アプリのテーマ
  • <activity>: 画面(Activity)の定義
    • android:name: Activityクラス名
    • android:exported="true": 外部から起動可能
    • <intent-filter>: 起動方法の定義
      • MAIN + LAUNCHER: アプリランチャーから起動

2. build.gradle.kts (Module: app)

モジュールの依存関係とビルド設定

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.example.todoapp"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.example.todoapp"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    
    kotlinOptions {
        jvmTarget = "11"
    }
    
    buildFeatures {
        compose = true
    }
}

dependencies {
    // AndroidX Core
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    
    // Compose
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    
    // Test
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    
    // Debug
    debugImplementation(libs.androidx.compose.ui.tooling)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}

主要な設定:

  • namespace: パッケージ名
  • compileSdk: コンパイル時のSDKバージョン
  • minSdk: サポートする最小SDKバージョン
  • targetSdk: ターゲットSDKバージョン
  • versionCode: アプリのバージョンコード(整数)
  • versionName: ユーザーに表示されるバージョン名
  • buildTypes: ビルドタイプ(debug/release)
  • dependencies: 依存ライブラリ

3. MainActivity.kt

デフォルトで生成されるメインActivity

package com.example.todoapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.todoapp.ui.theme.TodoAppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TodoAppTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    TodoAppTheme {
        Greeting("Android")
    }
}

2.3 Jetpack Compose基礎

2.3.1 Jetpack Composeとは

Jetpack Composeは、Androidの最新のUI構築ツールキットです。

特徴:

  • 宣言的UI: UIの状態を宣言するだけでOK
  • Kotlinベース: Kotlinの機能をフル活用
  • プレビュー機能: リアルタイムでUIを確認
  • コード量削減: XMLレイアウトが不要
  • 高いパフォーマンス: 効率的な再描画

従来のXML vs Compose:

<!-- XML (従来) -->
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello Android!" />
// Compose (新)
Text(text = "Hello Android!")

2.3.2 Composable関数の基本

Composable関数:

  • @Composableアノテーションを付ける
  • UIの一部を記述する関数
  • 関数名は大文字で始める(PascalCase)

基本構文:

@Composable
fun MyComponent() {
    Text(text = "Hello!")
}

パラメータを持つComposable:

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

Modifierの使用:

@Composable
fun StyledText() {
    Text(
        text = "Hello!",
        modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth()
            .background(Color.Blue),
        color = Color.White,
        fontSize = 20.sp
    )
}

2.3.3 基本的なComposeコンポーネント

1. Text - テキスト表示

@Composable
fun TextExamples() {
    Column {
        // 基本的なText
        Text(text = "Simple Text")
        
        // スタイル付きText
        Text(
            text = "Styled Text",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            color = Color.Blue
        )
        
        // 最大行数制限
        Text(
            text = "Very long text...",
            maxLines = 2,
            overflow = TextOverflow.Ellipsis
        )
    }
}

2. Button - ボタン

@Composable
fun ButtonExamples() {
    Column {
        // 基本的なButton
        Button(onClick = { /* 処理 */ }) {
            Text("Click Me")
        }
        
        // スタイル付きButton
        Button(
            onClick = { /* 処理 */ },
            colors = ButtonDefaults.buttonColors(
                containerColor = Color.Red,
                contentColor = Color.White
            )
        ) {
            Text("Red Button")
        }
        
        // 無効化されたButton
        Button(
            onClick = { },
            enabled = false
        ) {
            Text("Disabled")
        }
    }
}

3. TextField - テキスト入力

@Composable
fun TextFieldExample() {
    var text by remember { mutableStateOf("") }
    
    TextField(
        value = text,
        onValueChange = { text = it },
        label = { Text("Enter text") },
        placeholder = { Text("Placeholder") }
    )
}

4. Image - 画像表示

@Composable
fun ImageExample() {
    Image(
        painter = painterResource(id = R.drawable.ic_launcher_foreground),
        contentDescription = "App Icon",
        modifier = Modifier.size(100.dp)
    )
}

5. Icon - アイコン表示

@Composable
fun IconExample() {
    Icon(
        imageVector = Icons.Default.Favorite,
        contentDescription = "Favorite",
        tint = Color.Red
    )
}

2.3.4 レイアウトコンポーネント

1. Column - 縦方向のレイアウト

@Composable
fun ColumnExample() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("First")
        Text("Second")
        Text("Third")
    }
}

2. Row - 横方向のレイアウト

@Composable
fun RowExample() {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("Left")
        Text("Center")
        Text("Right")
    }
}

3. Box - 重ね合わせレイアウト

@Composable
fun BoxExample() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Image(...)  // 背景
        Text("Overlay Text")  // 前面
    }
}

4. LazyColumn - リスト表示(RecyclerView相当)

@Composable
fun LazyColumnExample() {
    val items = listOf("Item 1", "Item 2", "Item 3")
    
    LazyColumn {
        items(items) { item ->
            Text(
                text = item,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

5. Scaffold - 画面の基本構造

@Composable
fun ScaffoldExample() {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("My App") })
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { }) {
                Icon(Icons.Default.Add, contentDescription = "Add")
            }
        }
    ) { paddingValues ->
        // コンテンツ
        Column(modifier = Modifier.padding(paddingValues)) {
            Text("Content")
        }
    }
}

2.3.5 状態管理の基本

remember - 状態の保持

@Composable
fun CounterExample() {
    var count by remember { mutableStateOf(0) }
    
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

rememberSaveable - 画面回転時も状態を保持

@Composable
fun SaveableCounterExample() {
    var count by rememberSaveable { mutableStateOf(0) }
    
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

状態のホイスティング(State Hoisting):

// 状態を保持するComposable
@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }
    StatelessCounter(
        count = count,
        onIncrement = { count++ }
    )
}

// 状態を持たないComposable(再利用可能)
@Composable
fun StatelessCounter(
    count: Int,
    onIncrement: () -> Unit
) {
    Column {
        Text("Count: $count")
        Button(onClick = onIncrement) {
            Text("Increment")
        }
    }
}

2.4 基本的なUI作成

ToDoアプリの基本UIを作成していきます。

2.4.1 ToDoリストのUI設計

画面構成:

  1. TopAppBar: タイトル表示
  2. LazyColumn: ToDoリスト表示
  3. FloatingActionButton: 新規追加ボタン
  4. Dialog: ToDoの追加・編集ダイアログ

2.4.2 データクラスの定義

まず、ToDoアイテムのデータクラスを作成します。

ファイル: app/src/main/java/com/example/todoapp/TodoItem.kt

package com.example.todoapp

data class TodoItem(
    val id: Int,
    val title: String,
    val description: String = "",
    val isCompleted: Boolean = false
)

2.4.3 MainActivityの実装

ファイル: MainActivity.kt

package com.example.todoapp

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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.example.todoapp.ui.theme.TodoAppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TodoAppTheme {
                TodoScreen()
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoScreen() {
    // ToDoリストの状態
    var todoList by remember {
        mutableStateOf(
            listOf(
                TodoItem(1, "買い物", "牛乳とパンを買う"),
                TodoItem(2, "運動", "ランニング30分"),
                TodoItem(3, "勉強", "Kotlinの復習", true)
            )
        )
    }
    
    // ダイアログの表示状態
    var showDialog by remember { mutableStateOf(false) }
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("ToDoアプリ") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primary,
                    titleContentColor = MaterialTheme.colorScheme.onPrimary
                )
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = { showDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Add,
                    contentDescription = "追加"
                )
            }
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            if (todoList.isEmpty()) {
                // 空の状態
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        text = "ToDoがありません\n+ボタンで追加できます",
                        style = MaterialTheme.typography.bodyLarge,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            } else {
                // ToDoリスト表示
                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(16.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    items(
                        items = todoList,
                        key = { it.id }
                    ) { todo ->
                        TodoItemCard(
                            todoItem = todo,
                            onToggleComplete = { id ->
                                todoList = todoList.map {
                                    if (it.id == id) it.copy(isCompleted = !it.isCompleted)
                                    else it
                                }
                            },
                            onDelete = { id ->
                                todoList = todoList.filter { it.id != id }
                            }
                        )
                    }
                }
            }
        }
        
        // 追加ダイアログ
        if (showDialog) {
            AddTodoDialog(
                onDismiss = { showDialog = false },
                onAdd = { title, description ->
                    val newId = (todoList.maxOfOrNull { it.id } ?: 0) + 1
                    todoList = todoList + TodoItem(newId, title, description)
                    showDialog = false
                }
            )
        }
    }
}

@Composable
fun TodoItemCard(
    todoItem: TodoItem,
    onToggleComplete: (Int) -> Unit,
    onDelete: (Int) -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // チェックボックス
            Checkbox(
                checked = todoItem.isCompleted,
                onCheckedChange = { onToggleComplete(todoItem.id) }
            )
            
            Spacer(modifier = Modifier.width(8.dp))
            
            // ToDoの内容
            Column(
                modifier = Modifier.weight(1f)
            ) {
                Text(
                    text = todoItem.title,
                    style = MaterialTheme.typography.titleMedium,
                    textDecoration = if (todoItem.isCompleted) {
                        TextDecoration.LineThrough
                    } else null
                )
                if (todoItem.description.isNotEmpty()) {
                    Text(
                        text = todoItem.description,
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        textDecoration = if (todoItem.isCompleted) {
                            TextDecoration.LineThrough
                        } else null
                    )
                }
            }
            
            // 削除ボタン
            IconButton(onClick = { onDelete(todoItem.id) }) {
                Icon(
                    imageVector = Icons.Default.Delete,
                    contentDescription = "削除",
                    tint = MaterialTheme.colorScheme.error
                )
            }
        }
    }
}

@Composable
fun AddTodoDialog(
    onDismiss: () -> Unit,
    onAdd: (String, String) -> Unit
) {
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }
    
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("新しいToDo") },
        text = {
            Column {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("タイトル") },
                    modifier = Modifier.fillMaxWidth(),
                    singleLine = true
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                OutlinedTextField(
                    value = description,
                    onValueChange = { description = it },
                    label = { Text("説明(任意)") },
                    modifier = Modifier.fillMaxWidth(),
                    maxLines = 3
                )
            }
        },
        confirmButton = {
            TextButton(
                onClick = {
                    if (title.isNotBlank()) {
                        onAdd(title, description)
                    }
                },
                enabled = title.isNotBlank()
            ) {
                Text("追加")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("キャンセル")
            }
        }
    )
}

2.4.4 アプリの実行

Step 1: エミュレータまたは実機を起動

Step 2: アプリを実行

ツールバー: ▶ (Run) ボタンをクリック
ショートカット: Shift + F10 (Mac: Ctrl + R)

Step 3: 動作確認

  • ToDoリストが表示される
  • +ボタンでToDoを追加できる
  • チェックボックスで完了/未完了を切り替えられる
  • ゴミ箱アイコンでToDoを削除できる

2.5 レイアウトプレビューとデバッグ

2.5.1 Composeプレビュー機能

Jetpack Composeでは、実際にアプリを実行せずにUIをプレビューできます。

@Previewアノテーション:

@Preview(showBackground = true)
@Composable
fun TodoItemPreview() {
    TodoAppTheme {
        TodoItemCard(
            todoItem = TodoItem(
                id = 1,
                title = "サンプルToDo",
                description = "これはプレビューです"
            ),
            onToggleComplete = {},
            onDelete = {}
        )
    }
}

プレビューの表示:

  1. Composable関数に@Previewを追加
  2. エディタの右側に「Split」または「Design」タブが表示される
  3. プレビューが自動的に表示される

複数のプレビュー:

import android.content.res.Configuration

@Preview(name = "Light Mode", showBackground = true)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun TodoScreenPreview() {
    TodoAppTheme {
        TodoScreen()
    }
}

デバイスプレビュー:

import androidx.compose.ui.tooling.preview.Devices

@Preview(device = Devices.PIXEL_6)
@Preview(device = Devices.TABLET)
@Composable
fun TodoScreenDevicePreview() {
    TodoAppTheme {
        TodoScreen()
    }
}

インタラクティブモード:
プレビュー画面上部の「Interactive」ボタンをクリックすると、プレビュー内でボタンのクリックなどが可能になります。

2.5.2 Logcatの使い方

Logcatの開き方:

View → Tool Windows → Logcat
ショートカット: Alt + 6 (Mac: Cmd + 6)

ログの出力:

import android.util.Log

class MainActivity : ComponentActivity() {
    companion object {
        private const val TAG = "MainActivity"
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, "onCreate called")
        
        // 以降の処理...
    }
}

ログレベル:

  • Log.v(): Verbose(詳細)
  • Log.d(): Debug(デバッグ)
  • Log.i(): Info(情報)
  • Log.w(): Warning(警告)
  • Log.e(): Error(エラー)

Logcatのフィルタリング:

  1. パッケージフィルタ: 自分のアプリのログのみ表示
  2. タグフィルタ: 特定のTAGでフィルタ
  3. レベルフィルタ: ログレベルでフィルタ
  4. 検索: キーワードで検索

正規表現フィルタ:

tag:MainActivity
package:com.example.todoapp
level:ERROR

2.5.3 デバッグ実行

ブレークポイントの設定:

  1. コードの行番号の左側をクリック
  2. 赤い丸が表示される

デバッグ実行:

ツールバー: 🐛 (Debug) ボタンをクリック
ショートカット: Shift + F9 (Mac: Ctrl + D)

デバッグ操作:

  • Step Over (F8): 次の行へ
  • Step Into (F7): 関数の中に入る
  • Step Out (Shift + F8): 関数から出る
  • Resume (F9): 次のブレークポイントまで実行
  • Evaluate Expression (Alt + F8): 式の評価

変数の確認:

  • デバッグ実行中、Variables タブで変数の値を確認できる
  • 変数にマウスオーバーで値を表示
  • Watchesに式を追加して監視

2.5.4 レイアウトインスペクタ

実行中のアプリのUIを階層的に確認できます。

起動方法:

Tools → Layout Inspector

機能:

  • UIコンポーネントの階層表示
  • 各コンポーネントのプロパティ確認
  • 3D表示でレイアウトの重なりを確認

2.5.5 パフォーマンス分析

CPU Profiler:

View → Tool Windows → Profiler
  • CPU使用率の確認
  • メソッドの実行時間計測

Memory Profiler:

  • メモリ使用量の確認
  • メモリリークの検出

🎉 Part 2完了!
基本的なUIの作成ができました。次はMVVMアーキテクチャを導入してアプリを本格的に実装していきます。


Part 3: MVVM実装編(中級者向け)

⏱️ 学習時間の目安: 6〜8時間

3.1 MVVMアーキテクチャの理解

3.1.1 MVVMとは

MVVM (Model-View-ViewModel)は、UIとビジネスロジックを分離するアーキテクチャパターンです。

MVVMの構成要素:

┌─────────────────────────────────────────┐
│              View (UI)                  │
│  - Composable関数                       │
│  - Activity/Fragment                    │
│  - UIの表示のみを担当                    │
└──────────────┬──────────────────────────┘
               │ observe
               │ event
┌──────────────▼──────────────────────────┐
│           ViewModel                     │
│  - UI状態の管理                          │
│  - ビジネスロジック                       │
│  - Repositoryとの連携                    │
└──────────────┬──────────────────────────┘
               │ data
               │ request
┌──────────────▼──────────────────────────┐
│            Repository                   │
│  - データソースの抽象化                   │
│  - データの取得・保存                     │
└──────────────┬──────────────────────────┘
               │
       ┌───────┴────────┐
       │                │
┌──────▼─────┐  ┌──────▼─────┐
│Local Data  │  │Remote Data │
│(Room DB)   │  │(API)       │
└────────────┘  └────────────┘

各層の責務:

View:

  • UIの表示
  • ユーザーインタラクションの受け取り
  • ViewModelの状態を監視(observe)

ViewModel:

  • UI状態の保持
  • ビジネスロジックの実行
  • Repositoryからのデータ取得
  • 画面回転などの設定変更に耐える

Repository:

  • データソースの抽象化
  • ローカルDB、API、キャッシュの管理
  • データの取得・保存ロジック

Model:

  • データクラス
  • ビジネスロジック

3.1.2 MVVMのメリット

  1. テストしやすい: 各層を独立してテスト可能
  2. 保守性が高い: 責務が明確で変更の影響範囲が限定的
  3. 再利用性: ViewModelやRepositoryを複数の画面で共有可能
  4. 設定変更に強い: ViewModelが状態を保持

3.2 ViewModelの実装

3.2.1 依存関係の追加

まず、必要なライブラリを追加します。

ファイル: build.gradle.kts (Module: app)

dependencies {
    // 既存の依存関係...
    
    // ViewModel
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
    
    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
    
    // 後で追加するもの(先に記載)
    // Room Database
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")
    
    // Hilt
    implementation("com.google.dagger:hilt-android:2.50")
    ksp("com.google.dagger:hilt-compiler:2.50")
    implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
}

KSPプラグインの追加:

build.gradle.kts (Module: app)の最上部に追加:

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    id("com.google.devtools.ksp") version "1.9.22-1.0.17"  // 追加
}

Gradle同期:

ツールバー: "Sync Now" をクリック

3.2.2 UI状態の定義

ファイル: app/src/main/java/com/example/todoapp/TodoUiState.kt

package com.example.todoapp

/**
 * ToDoリスト画面のUI状態
 */
data class TodoUiState(
    val todos: List<TodoItem> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

/**
 * ToDoアイテムのUI表示用イベント
 */
sealed interface TodoEvent {
    data class ShowError(val message: String) : TodoEvent
    data object TodoAdded : TodoEvent
    data object TodoDeleted : TodoEvent
}

3.2.3 ViewModelの実装

ファイル: app/src/main/java/com/example/todoapp/TodoViewModel.kt

package com.example.todoapp

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class TodoViewModel : ViewModel() {
    
    // UI状態の保持(private mutable, public immutable)
    private val _uiState = MutableStateFlow(TodoUiState())
    val uiState: StateFlow<TodoUiState> = _uiState.asStateFlow()
    
    // 初期データの設定
    init {
        loadInitialData()
    }
    
    /**
     * 初期データのロード
     */
    private fun loadInitialData() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            
            // 後でRepositoryから取得する
            // 今はダミーデータ
            val dummyData = listOf(
                TodoItem(1, "買い物", "牛乳とパンを買う"),
                TodoItem(2, "運動", "ランニング30分"),
                TodoItem(3, "勉強", "Kotlinの復習")
            )
            
            _uiState.update {
                it.copy(
                    todos = dummyData,
                    isLoading = false
                )
            }
        }
    }
    
    /**
     * ToDoの追加
     */
    fun addTodo(title: String, description: String) {
        viewModelScope.launch {
            val currentTodos = _uiState.value.todos
            val newId = (currentTodos.maxOfOrNull { it.id } ?: 0) + 1
            val newTodo = TodoItem(
                id = newId,
                title = title,
                description = description
            )
            
            _uiState.update {
                it.copy(todos = currentTodos + newTodo)
            }
        }
    }
    
    /**
     * ToDoの完了状態切り替え
     */
    fun toggleTodoComplete(id: Int) {
        viewModelScope.launch {
            _uiState.update { state ->
                state.copy(
                    todos = state.todos.map { todo ->
                        if (todo.id == id) {
                            todo.copy(isCompleted = !todo.isCompleted)
                        } else {
                            todo
                        }
                    }
                )
            }
        }
    }
    
    /**
     * ToDoの削除
     */
    fun deleteTodo(id: Int) {
        viewModelScope.launch {
            _uiState.update { state ->
                state.copy(
                    todos = state.todos.filter { it.id != id }
                )
            }
        }
    }
    
    /**
     * エラーメッセージのクリア
     */
    fun clearError() {
        _uiState.update { it.copy(errorMessage = null) }
    }
}

3.2.4 ViewModelを使ったUI実装

ファイル: MainActivity.ktを更新

package com.example.todoapp

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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.todoapp.ui.theme.TodoAppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TodoAppTheme {
                TodoScreen()
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoScreen(
    viewModel: TodoViewModel = viewModel()
) {
    // ViewModelの状態を監視
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    // ダイアログの表示状態
    var showDialog by remember { mutableStateOf(false) }
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("ToDoアプリ") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primary,
                    titleContentColor = MaterialTheme.colorScheme.onPrimary
                )
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = { showDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Add,
                    contentDescription = "追加"
                )
            }
        }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            // ローディング表示
            if (uiState.isLoading) {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center)
                )
            } else if (uiState.todos.isEmpty()) {
                // 空の状態
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        text = "ToDoがありません\n+ボタンで追加できます",
                        style = MaterialTheme.typography.bodyLarge,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            } else {
                // ToDoリスト表示
                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(16.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    items(
                        items = uiState.todos,
                        key = { it.id }
                    ) { todo ->
                        TodoItemCard(
                            todoItem = todo,
                            onToggleComplete = { viewModel.toggleTodoComplete(it) },
                            onDelete = { viewModel.deleteTodo(it) }
                        )
                    }
                }
            }
        }
        
        // エラーメッセージ表示
        uiState.errorMessage?.let { error ->
            Snackbar(
                modifier = Modifier.padding(16.dp)
            ) {
                Text(error)
            }
        }
        
        // 追加ダイアログ
        if (showDialog) {
            AddTodoDialog(
                onDismiss = { showDialog = false },
                onAdd = { title, description ->
                    viewModel.addTodo(title, description)
                    showDialog = false
                }
            )
        }
    }
}

@Composable
fun TodoItemCard(
    todoItem: TodoItem,
    onToggleComplete: (Int) -> Unit,
    onDelete: (Int) -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // チェックボックス
            Checkbox(
                checked = todoItem.isCompleted,
                onCheckedChange = { onToggleComplete(todoItem.id) }
            )
            
            Spacer(modifier = Modifier.width(8.dp))
            
            // ToDoの内容
            Column(
                modifier = Modifier.weight(1f)
            ) {
                Text(
                    text = todoItem.title,
                    style = MaterialTheme.typography.titleMedium,
                    textDecoration = if (todoItem.isCompleted) {
                        TextDecoration.LineThrough
                    } else null
                )
                if (todoItem.description.isNotEmpty()) {
                    Text(
                        text = todoItem.description,
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        textDecoration = if (todoItem.isCompleted) {
                            TextDecoration.LineThrough
                        } else null
                    )
                }
            }
            
            // 削除ボタン
            IconButton(onClick = { onDelete(todoItem.id) }) {
                Icon(
                    imageVector = Icons.Default.Delete,
                    contentDescription = "削除",
                    tint = MaterialTheme.colorScheme.error
                )
            }
        }
    }
}

@Composable
fun AddTodoDialog(
    onDismiss: () -> Unit,
    onAdd: (String, String) -> Unit
) {
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }
    
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("新しいToDo") },
        text = {
            Column {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("タイトル") },
                    modifier = Modifier.fillMaxWidth(),
                    singleLine = true
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                OutlinedTextField(
                    value = description,
                    onValueChange = { description = it },
                    label = { Text("説明(任意)") },
                    modifier = Modifier.fillMaxWidth(),
                    maxLines = 3
                )
            }
        },
        confirmButton = {
            TextButton(
                onClick = {
                    if (title.isNotBlank()) {
                        onAdd(title, description)
                    }
                },
                enabled = title.isNotBlank()
            ) {
                Text("追加")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("キャンセル")
            }
        }
    )
}

3.3 Room Databaseのセットアップ

3.3.1 Room とは

Roomは、SQLiteデータベースの上に構築されたAndroidの永続化ライブラリです。

特徴:

  • コンパイル時のSQLクエリ検証
  • ボイラープレートコードの削減
  • Coroutines/Flowとの統合
  • マイグレーション機能

Roomの構成要素:

  • Entity: データベースのテーブル
  • DAO (Data Access Object): データベース操作のインターフェース
  • Database: データベースの設定と管理

3.3.2 Entityの作成

ファイル: app/src/main/java/com/example/todoapp/data/local/TodoEntity.kt

package com.example.todoapp.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "todos")
data class TodoEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val description: String,
    val isCompleted: Boolean,
    val createdAt: Long,
    val updatedAt: Long
)

3.3.3 DAOの作成

ファイル: app/src/main/java/com/example/todoapp/data/local/TodoDao.kt

package com.example.todoapp.data.local

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface TodoDao {
    
    /**
     * 全てのToDoを取得(Flowで監視可能)
     */
    @Query("SELECT * FROM todos ORDER BY createdAt DESC")
    fun getAllTodos(): Flow<List<TodoEntity>>
    
    /**
     * IDでToDoを取得
     */
    @Query("SELECT * FROM todos WHERE id = :id")
    suspend fun getTodoById(id: Int): TodoEntity?
    
    /**
     * ToDoを挿入
     */
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTodo(todo: TodoEntity): Long
    
    /**
     * 複数のToDoを挿入
     */
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTodos(todos: List<TodoEntity>)
    
    /**
     * ToDoを更新
     */
    @Update
    suspend fun updateTodo(todo: TodoEntity)
    
    /**
     * ToDoを削除
     */
    @Delete
    suspend fun deleteTodo(todo: TodoEntity)
    
    /**
     * IDでToDoを削除
     */
    @Query("DELETE FROM todos WHERE id = :id")
    suspend fun deleteTodoById(id: Int)
    
    /**
     * 全てのToDoを削除
     */
    @Query("DELETE FROM todos")
    suspend fun deleteAllTodos()
    
    /**
     * 完了したToDoのみ取得
     */
    @Query("SELECT * FROM todos WHERE isCompleted = 1 ORDER BY updatedAt DESC")
    fun getCompletedTodos(): Flow<List<TodoEntity>>
    
    /**
     * 未完了のToDoのみ取得
     */
    @Query("SELECT * FROM todos WHERE isCompleted = 0 ORDER BY createdAt DESC")
    fun getActiveTodos(): Flow<List<TodoEntity>>
}

3.3.4 Databaseの作成

ファイル: app/src/main/java/com/example/todoapp/data/local/TodoDatabase.kt

package com.example.todoapp.data.local

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(
    entities = [TodoEntity::class],
    version = 1,
    exportSchema = false
)
abstract class TodoDatabase : RoomDatabase() {
    
    abstract fun todoDao(): TodoDao
    
    companion object {
        @Volatile
        private var INSTANCE: TodoDatabase? = null
        
        fun getDatabase(context: Context): TodoDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    TodoDatabase::class.java,
                    "todo_database"
                )
                    .fallbackToDestructiveMigration()
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

3.4 Repository層の実装

3.4.1 データマッパーの作成

EntityとDomainモデル(TodoItem)の変換を行います。

ファイル: app/src/main/java/com/example/todoapp/data/mapper/TodoMapper.kt

package com.example.todoapp.data.mapper

import com.example.todoapp.TodoItem
import com.example.todoapp.data.local.TodoEntity

/**
 * TodoEntity → TodoItem
 */
fun TodoEntity.toDomainModel(): TodoItem {
    return TodoItem(
        id = id,
        title = title,
        description = description,
        isCompleted = isCompleted
    )
}

/**
 * TodoItem → TodoEntity
 */
fun TodoItem.toEntity(): TodoEntity {
    val currentTime = System.currentTimeMillis()
    return TodoEntity(
        id = id,
        title = title,
        description = description,
        isCompleted = isCompleted,
        createdAt = currentTime,
        updatedAt = currentTime
    )
}

/**
 * List<TodoEntity> → List<TodoItem>
 */
fun List<TodoEntity>.toDomainModels(): List<TodoItem> {
    return map { it.toDomainModel() }
}

3.4.2 Repositoryインターフェースの定義

ファイル: app/src/main/java/com/example/todoapp/domain/repository/TodoRepository.kt

package com.example.todoapp.domain.repository

import com.example.todoapp.TodoItem
import kotlinx.coroutines.flow.Flow

interface TodoRepository {
    
    /**
     * 全てのToDoを取得
     */
    fun getAllTodos(): Flow<List<TodoItem>>
    
    /**
     * IDでToDoを取得
     */
    suspend fun getTodoById(id: Int): TodoItem?
    
    /**
     * ToDoを追加
     */
    suspend fun insertTodo(todo: TodoItem): Long
    
    /**
     * ToDoを更新
     */
    suspend fun updateTodo(todo: TodoItem)
    
    /**
     * ToDoを削除
     */
    suspend fun deleteTodo(id: Int)
    
    /**
     * 完了状態を切り替え
     */
    suspend fun toggleTodoComplete(id: Int)
}

3.4.3 Repository実装

ファイル: app/src/main/java/com/example/todoapp/data/repository/TodoRepositoryImpl.kt

package com.example.todoapp.data.repository

import com.example.todoapp.TodoItem
import com.example.todoapp.data.local.TodoDao
import com.example.todoapp.data.mapper.toDomainModel
import com.example.todoapp.data.mapper.toDomainModels
import com.example.todoapp.data.mapper.toEntity
import com.example.todoapp.domain.repository.TodoRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class TodoRepositoryImpl(
    private val todoDao: TodoDao
) : TodoRepository {
    
    override fun getAllTodos(): Flow<List<TodoItem>> {
        return todoDao.getAllTodos()
            .map { entities -> entities.toDomainModels() }
    }
    
    override suspend fun getTodoById(id: Int): TodoItem? {
        return todoDao.getTodoById(id)?.toDomainModel()
    }
    
    override suspend fun insertTodo(todo: TodoItem): Long {
        return todoDao.insertTodo(todo.toEntity())
    }
    
    override suspend fun updateTodo(todo: TodoItem) {
        todoDao.updateTodo(todo.toEntity())
    }
    
    override suspend fun deleteTodo(id: Int) {
        todoDao.deleteTodoById(id)
    }
    
    override suspend fun toggleTodoComplete(id: Int) {
        val todo = todoDao.getTodoById(id) ?: return
        val updatedTodo = todo.copy(
            isCompleted = !todo.isCompleted,
            updatedAt = System.currentTimeMillis()
        )
        todoDao.updateTodo(updatedTodo)
    }
}

3.5 Hiltによる依存性注入

3.5.1 Hilt とは

Hiltは、Androidアプリ向けの依存性注入(DI)ライブラリです。

メリット:

  • ボイラープレートコードの削減
  • 依存関係の管理が容易
  • テストしやすい
  • ライフサイクルを考慮した依存関係の提供

3.5.2 Hiltのセットアップ

build.gradle.kts (Project) にHiltプラグインを追加:

plugins {
    // 既存のプラグイン...
    id("com.google.dagger.hilt.android") version "2.50" apply false
}

build.gradle.kts (Module: app) を更新:

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")  // 追加
}

// ... 既存の設定 ...

dependencies {
    // 既存の依存関係...
    
    // Hilt
    implementation("com.google.dagger:hilt-android:2.50")
    ksp("com.google.dagger:hilt-compiler:2.50")
    implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
}

Gradle同期を実行

3.5.3 Applicationクラスの作成

ファイル: app/src/main/java/com/example/todoapp/TodoApplication.kt

package com.example.todoapp

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class TodoApplication : Application()

AndroidManifest.xml を更新:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="[Android名前空間URL]"
    xmlns:tools="[Tools名前空間URL]">

    <application
        android:name=".TodoApplication"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.TodoApp"
        tools:targetApi="31">
        
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:theme="@style/Theme.TodoApp">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        
    </application>

</manifest>

3.5.4 DIモジュールの作成

ファイル: app/src/main/java/com/example/todoapp/di/DatabaseModule.kt

package com.example.todoapp.di

import android.content.Context
import com.example.todoapp.data.local.TodoDao
import com.example.todoapp.data.local.TodoDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    
    @Provides
    @Singleton
    fun provideTodoDatabase(
        @ApplicationContext context: Context
    ): TodoDatabase {
        return TodoDatabase.getDatabase(context)
    }
    
    @Provides
    @Singleton
    fun provideTodoDao(database: TodoDatabase): TodoDao {
        return database.todoDao()
    }
}

ファイル: app/src/main/java/com/example/todoapp/di/RepositoryModule.kt

package com.example.todoapp.di

import com.example.todoapp.data.local.TodoDao
import com.example.todoapp.data.repository.TodoRepositoryImpl
import com.example.todoapp.domain.repository.TodoRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    
    @Provides
    @Singleton
    fun provideTodoRepository(
        todoDao: TodoDao
    ): TodoRepository {
        return TodoRepositoryImpl(todoDao)
    }
}

3.5.5 ViewModelへの依存性注入

ファイル: TodoViewModel.kt を更新

package com.example.todoapp

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.todoapp.domain.repository.TodoRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class TodoViewModel @Inject constructor(
    private val repository: TodoRepository
) : ViewModel() {
    
    // UI状態の保持
    private val _uiState = MutableStateFlow(TodoUiState())
    val uiState: StateFlow<TodoUiState> = _uiState.asStateFlow()
    
    init {
        loadTodos()
    }
    
    /**
     * ToDoリストの読み込み
     */
    private fun loadTodos() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            
            repository.getAllTodos()
                .catch { exception ->
                    _uiState.update {
                        it.copy(
                            isLoading = false,
                            errorMessage = "データの読み込みに失敗しました: ${exception.message}"
                        )
                    }
                }
                .collect { todos ->
                    _uiState.update {
                        it.copy(
                            todos = todos,
                            isLoading = false,
                            errorMessage = null
                        )
                    }
                }
        }
    }
    
    /**
     * ToDoの追加
     */
    fun addTodo(title: String, description: String) {
        if (title.isBlank()) {
            _uiState.update {
                it.copy(errorMessage = "タイトルを入力してください")
            }
            return
        }
        
        viewModelScope.launch {
            try {
                val newTodo = TodoItem(
                    id = 0, // auto-generate
                    title = title,
                    description = description,
                    isCompleted = false
                )
                repository.insertTodo(newTodo)
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(errorMessage = "追加に失敗しました: ${e.message}")
                }
            }
        }
    }
    
    /**
     * ToDoの完了状態切り替え
     */
    fun toggleTodoComplete(id: Int) {
        viewModelScope.launch {
            try {
                repository.toggleTodoComplete(id)
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(errorMessage = "更新に失敗しました: ${e.message}")
                }
            }
        }
    }
    
    /**
     * ToDoの削除
     */
    fun deleteTodo(id: Int) {
        viewModelScope.launch {
            try {
                repository.deleteTodo(id)
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(errorMessage = "削除に失敗しました: ${e.message}")
                }
            }
        }
    }
    
    /**
     * エラーメッセージのクリア
     */
    fun clearError() {
        _uiState.update { it.copy(errorMessage = null) }
    }
}

3.5.6 MainActivityの更新

ファイル: MainActivity.kt を更新

package com.example.todoapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.todoapp.ui.theme.TodoAppTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TodoAppTheme {
                TodoScreen()
            }
        }
    }
}

3.6 Coroutines/Flowの活用

3.6.1 Coroutinesとは

Coroutinesは、Kotlinの非同期処理ライブラリです。

特徴:

  • 軽量なスレッド
  • 構造化された並行処理
  • キャンセル処理が簡単
  • 順次処理のように書ける

基本的な使い方:

viewModelScope.launch {
    // 非同期処理
    val result = withContext(Dispatchers.IO) {
        // バックグラウンド処理
        repository.getData()
    }
    // UIスレッドで結果を処理
    updateUI(result)
}

Dispatcher:

  • Dispatchers.Main: UIスレッド
  • Dispatchers.IO: IO処理(ネットワーク、DB)
  • Dispatchers.Default: CPU集約的な処理
  • Dispatchers.Unconfined: 特定のスレッドに制限なし

3.6.2 Flowとは

Flowは、複数の値を順次発行できる非同期ストリームです。

LiveData vs Flow:

特徴 LiveData Flow
Android依存 あり なし
ライフサイクル対応 自動 手動(collectAsStateWithLifecycle)
柔軟性 低い 高い(演算子が豊富)
テスト やや難しい 簡単

Flowの基本的な使い方:

// Flowの作成
fun getDataFlow(): Flow<List<Data>> = flow {
    val data = fetchData()
    emit(data)
}

// Flowの収集
viewModelScope.launch {
    getDataFlow()
        .catch { e -> handleError(e) }
        .collect { data ->
            updateUI(data)
        }
}

Flowの演算子:

repository.getAllTodos()
    .map { todos -> todos.filter { !it.isCompleted } }  // 変換
    .catch { e -> emit(emptyList()) }  // エラーハンドリング
    .onStart { emit(emptyList()) }  // 開始時の処理
    .onCompletion { /* 完了時の処理 */ }  // 完了時の処理
    .collect { todos -> updateUI(todos) }  // 収集

3.6.3 StateFlowの活用

StateFlowは、状態を保持するHot Flowです。

特徴:

  • 最新の値を保持
  • 同じ値の連続発行を無視
  • LiveDataの代替として使用可能

使用例:

class TodoViewModel @Inject constructor(
    private val repository: TodoRepository
) : ViewModel() {
    
    private val _uiState = MutableStateFlow(TodoUiState())
    val uiState: StateFlow<TodoUiState> = _uiState.asStateFlow()
    
    init {
        viewModelScope.launch {
            repository.getAllTodos()
                .collect { todos ->
                    _uiState.update { it.copy(todos = todos) }
                }
        }
    }
}

3.6.4 エラーハンドリング

try-catch:

viewModelScope.launch {
    try {
        val result = repository.getData()
        _uiState.update { it.copy(data = result) }
    } catch (e: Exception) {
        _uiState.update { it.copy(error = e.message) }
    }
}

Flow.catch:

repository.getAllTodos()
    .catch { e ->
        _uiState.update {
            it.copy(errorMessage = "エラー: ${e.message}")
        }
        emit(emptyList())  // デフォルト値を発行
    }
    .collect { todos ->
        _uiState.update { it.copy(todos = todos) }
    }

3.6.5 パフォーマンス最適化

debounce: 連続した入力を間引く

searchQuery
    .debounce(300)  // 300ms待つ
    .distinctUntilChanged()  // 同じ値は無視
    .collect { query ->
        search(query)
    }

combine: 複数のFlowを結合

combine(
    repository.getAllTodos(),
    filterStateFlow
) { todos, filter ->
    when (filter) {
        Filter.ALL -> todos
        Filter.ACTIVE -> todos.filter { !it.isCompleted }
        Filter.COMPLETED -> todos.filter { it.isCompleted }
    }
}.collect { filteredTodos ->
    _uiState.update { it.copy(todos = filteredTodos) }
}

🎉 Part 3完了!
MVVM、Room Database、Hiltを使った実装ができました。次はClean Architectureでさらに保守性の高い設計を学びます。


Part 4: Clean Architecture実装編(上級者向け)

⏱️ 学習時間の目安: 8〜10時間

4.1 Clean Architectureの理解

4.1.1 Clean Architectureとは

Clean Architectureは、ソフトウェアの保守性・テスタビリティを高めるアーキテクチャパターンです。

レイヤー構造:

┌─────────────────────────────────────────────────────┐
│          Presentation Layer (UI)                    │
│  - Composable関数                                   │
│  - Activity/Fragment                                │
│  - ViewModel                                        │
└──────────────────┬──────────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────────┐
│          Domain Layer (ビジネスロジック)              │
│  - UseCases (ビジネスロジック)                       │
│  - Domain Models (エンティティ)                      │
│  - Repository Interfaces                            │
└──────────────────┬──────────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────────┐
│          Data Layer (データソース)                   │
│  - Repository Implementations                       │
│  - Data Sources (Local/Remote)                      │
│  - Mappers                                          │
│  - DTOs/Entities                                    │
└─────────────────────────────────────────────────────┘

依存関係のルール:

  • 外側の層は内側の層に依存できる
  • 内側の層は外側の層に依存してはいけない
  • Domain層は他のどの層にも依存しない

4.1.2 各層の責務

Presentation Layer:

  • UI表示
  • ユーザー入力の受け取り
  • ViewModelでUseCaseを呼び出し

Domain Layer:

  • ビジネスロジックの実装
  • アプリケーション固有のルール
  • フレームワーク非依存

Data Layer:

  • データの取得・保存
  • API通信
  • ローカルDB操作
  • キャッシュ管理

4.1.3 Clean Architectureのメリット

  1. テストしやすい: 各層を独立してテスト可能
  2. 保守性が高い: 責務が明確
  3. 技術的負債の軽減: フレームワーク変更の影響が限定的
  4. 並行開発が容易: 層ごとに開発可能
  5. 再利用性: UseCaseを複数の画面で使用可能

4.2 レイヤー分離の実装

4.2.1 プロジェクト構造の再編成

Clean Architectureに基づいてディレクトリ構造を整理します。

app/src/main/java/com/example/todoapp/
├── presentation/              # Presentation Layer
│   ├── TodoScreen.kt
│   ├── TodoViewModel.kt
│   ├── components/
│   │   ├── TodoItemCard.kt
│   │   └── AddTodoDialog.kt
│   └── model/
│       └── TodoUiState.kt
│
├── domain/                    # Domain Layer
│   ├── model/
│   │   └── TodoItem.kt       # Domain Model
│   ├── repository/
│   │   └── TodoRepository.kt # Repository Interface
│   └── usecase/
│       ├── GetAllTodosUseCase.kt
│       ├── AddTodoUseCase.kt
│       ├── UpdateTodoUseCase.kt
│       ├── DeleteTodoUseCase.kt
│       └── ToggleTodoCompleteUseCase.kt
│
├── data/                      # Data Layer
│   ├── repository/
│   │   └── TodoRepositoryImpl.kt
│   ├── local/
│   │   ├── TodoDatabase.kt
│   │   ├── TodoDao.kt
│   │   └── TodoEntity.kt
│   └── mapper/
│       └── TodoMapper.kt
│
├── di/                        # Dependency Injection
│   ├── DatabaseModule.kt
│   ├── RepositoryModule.kt
│   └── UseCaseModule.kt
│
├── MainActivity.kt
└── TodoApplication.kt

4.2.2 Domain Modelの移動

既存のTodoItem.ktdomain/modelパッケージに移動します。

ファイル: app/src/main/java/com/example/todoapp/domain/model/TodoItem.kt

package com.example.todoapp.domain.model

data class TodoItem(
    val id: Int,
    val title: String,
    val description: String = "",
    val isCompleted: Boolean = false
)

4.2.3 既存ファイルの移動

以下のファイルを適切なパッケージに移動:

  1. Repository Interface: domain/repository/に移動
  2. Repository Implementation: data/repository/に移動
  3. Database関連: data/local/に移動
  4. Mapper: data/mapper/に移動
  5. ViewModel: presentation/に移動

ファイル移動の手順:

  1. Android Studioでファイルを選択
  2. 右クリック → Refactor → Move
  3. 新しいパッケージを指定
  4. Android Studioが自動的にimport文を更新

⚠️ 注意: Part 3で作成したファイルをPart 4の構造に合わせて移動する必要があります。または、Part 4から新規にプロジェクトを作成することも可能です。

4.3 UseCaseの実装

4.3.1 Base UseCase

すべてのUseCaseの基底クラスを作成します。

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/base/UseCase.kt

package com.example.todoapp.domain.usecase.base

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
 * パラメータを受け取り、結果を返すUseCase
 */
abstract class UseCase<in P, out R>(
    private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend operator fun invoke(parameters: P): R {
        return withContext(coroutineDispatcher) {
            execute(parameters)
        }
    }

    @Throws(RuntimeException::class)
    protected abstract suspend fun execute(parameters: P): R
}

/**
 * パラメータを受け取らず、結果を返すUseCase
 */
abstract class NoParamUseCase<out R>(
    private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend operator fun invoke(): R {
        return withContext(coroutineDispatcher) {
            execute()
        }
    }

    @Throws(RuntimeException::class)
    protected abstract suspend fun execute(): R
}

/**
 * パラメータを受け取り、Flowを返すUseCase
 */
abstract class FlowUseCase<in P, out R>(
    private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend operator fun invoke(parameters: P): kotlinx.coroutines.flow.Flow<R> {
        return withContext(coroutineDispatcher) {
            execute(parameters)
        }
    }

    @Throws(RuntimeException::class)
    protected abstract suspend fun execute(parameters: P): kotlinx.coroutines.flow.Flow<R>
}

/**
 * パラメータを受け取らず、Flowを返すUseCase
 */
abstract class NoParamFlowUseCase<out R>(
    private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend operator fun invoke(): kotlinx.coroutines.flow.Flow<R> {
        return withContext(coroutineDispatcher) {
            execute()
        }
    }

    @Throws(RuntimeException::class)
    protected abstract suspend fun execute(): kotlinx.coroutines.flow.Flow<R>
}

4.3.2 個別UseCaseの実装

1. GetAllTodosUseCase - 全てのToDoを取得

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/GetAllTodosUseCase.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.domain.repository.TodoRepository
import com.example.todoapp.domain.usecase.base.NoParamFlowUseCase
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

/**
 * 全てのToDoを取得するUseCase
 */
class GetAllTodosUseCase @Inject constructor(
    private val repository: TodoRepository
) : NoParamFlowUseCase<List<TodoItem>>() {
    
    override suspend fun execute(): Flow<List<TodoItem>> {
        return repository.getAllTodos()
    }
}

2. AddTodoUseCase - ToDoを追加

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/AddTodoUseCase.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.domain.repository.TodoRepository
import com.example.todoapp.domain.usecase.base.UseCase
import javax.inject.Inject

/**
 * ToDoを追加するUseCase
 */
class AddTodoUseCase @Inject constructor(
    private val repository: TodoRepository
) : UseCase<AddTodoUseCase.Params, Long>() {
    
    override suspend fun execute(parameters: Params): Long {
        // ビジネスロジック: タイトルの検証
        if (parameters.title.isBlank()) {
            throw IllegalArgumentException("タイトルを入力してください")
        }
        
        if (parameters.title.length > 100) {
            throw IllegalArgumentException("タイトルは100文字以内で入力してください")
        }
        
        val todo = TodoItem(
            id = 0,
            title = parameters.title.trim(),
            description = parameters.description.trim(),
            isCompleted = false
        )
        
        return repository.insertTodo(todo)
    }
    
    data class Params(
        val title: String,
        val description: String
    )
}

3. UpdateTodoUseCase - ToDoを更新

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/UpdateTodoUseCase.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.domain.repository.TodoRepository
import com.example.todoapp.domain.usecase.base.UseCase
import javax.inject.Inject

/**
 * ToDoを更新するUseCase
 */
class UpdateTodoUseCase @Inject constructor(
    private val repository: TodoRepository
) : UseCase<TodoItem, Unit>() {
    
    override suspend fun execute(parameters: TodoItem) {
        // ビジネスロジック: タイトルの検証
        if (parameters.title.isBlank()) {
            throw IllegalArgumentException("タイトルを入力してください")
        }
        
        repository.updateTodo(parameters)
    }
}

4. DeleteTodoUseCase - ToDoを削除

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/DeleteTodoUseCase.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.repository.TodoRepository
import com.example.todoapp.domain.usecase.base.UseCase
import javax.inject.Inject

/**
 * ToDoを削除するUseCase
 */
class DeleteTodoUseCase @Inject constructor(
    private val repository: TodoRepository
) : UseCase<Int, Unit>() {
    
    override suspend fun execute(parameters: Int) {
        repository.deleteTodo(parameters)
    }
}

5. ToggleTodoCompleteUseCase - 完了状態を切り替え

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/ToggleTodoCompleteUseCase.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.repository.TodoRepository
import com.example.todoapp.domain.usecase.base.UseCase
import javax.inject.Inject

/**
 * ToDoの完了状態を切り替えるUseCase
 */
class ToggleTodoCompleteUseCase @Inject constructor(
    private val repository: TodoRepository
) : UseCase<Int, Unit>() {
    
    override suspend fun execute(parameters: Int) {
        repository.toggleTodoComplete(parameters)
    }
}

6. GetActiveTodosUseCase - 未完了のToDoのみ取得

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/GetActiveTodosUseCase.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.domain.usecase.base.NoParamFlowUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

/**
 * 未完了のToDoのみを取得するUseCase
 */
class GetActiveTodosUseCase @Inject constructor(
    private val getAllTodosUseCase: GetAllTodosUseCase
) : NoParamFlowUseCase<List<TodoItem>>() {
    
    override suspend fun execute(): Flow<List<TodoItem>> {
        return getAllTodosUseCase()
            .map { todos -> todos.filter { !it.isCompleted } }
    }
}

7. GetCompletedTodosUseCase - 完了済みのToDoのみ取得

ファイル: app/src/main/java/com/example/todoapp/domain/usecase/GetCompletedTodosUseCase.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.domain.usecase.base.NoParamFlowUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

/**
 * 完了済みのToDoのみを取得するUseCase
 */
class GetCompletedTodosUseCase @Inject constructor(
    private val getAllTodosUseCase: GetAllTodosUseCase
) : NoParamFlowUseCase<List<TodoItem>>() {
    
    override suspend fun execute(): Flow<List<TodoItem>> {
        return getAllTodosUseCase()
            .map { todos -> todos.filter { it.isCompleted } }
    }
}

4.3.3 ViewModelの更新

UseCaseを使用するようにViewModelを更新します。

ファイル: app/src/main/java/com/example/todoapp/presentation/TodoViewModel.kt

package com.example.todoapp.presentation

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.todoapp.domain.usecase.*
import com.example.todoapp.presentation.model.TodoUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class TodoViewModel @Inject constructor(
    private val getAllTodosUseCase: GetAllTodosUseCase,
    private val addTodoUseCase: AddTodoUseCase,
    private val deleteTodoUseCase: DeleteTodoUseCase,
    private val toggleTodoCompleteUseCase: ToggleTodoCompleteUseCase
) : ViewModel() {
    
    // UI状態の保持
    private val _uiState = MutableStateFlow(TodoUiState())
    val uiState: StateFlow<TodoUiState> = _uiState.asStateFlow()
    
    init {
        loadTodos()
    }
    
    /**
     * ToDoリストの読み込み
     */
    private fun loadTodos() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            
            try {
                getAllTodosUseCase()
                    .catch { exception ->
                        _uiState.update {
                            it.copy(
                                isLoading = false,
                                errorMessage = "データの読み込みに失敗しました: ${exception.message}"
                            )
                        }
                    }
                    .collect { todos ->
                        _uiState.update {
                            it.copy(
                                todos = todos,
                                isLoading = false,
                                errorMessage = null
                            )
                        }
                    }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(
                        isLoading = false,
                        errorMessage = "エラーが発生しました: ${e.message}"
                    )
                }
            }
        }
    }
    
    /**
     * ToDoの追加
     */
    fun addTodo(title: String, description: String) {
        viewModelScope.launch {
            try {
                addTodoUseCase(
                    AddTodoUseCase.Params(
                        title = title,
                        description = description
                    )
                )
            } catch (e: IllegalArgumentException) {
                _uiState.update {
                    it.copy(errorMessage = e.message)
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(errorMessage = "追加に失敗しました: ${e.message}")
                }
            }
        }
    }
    
    /**
     * ToDoの完了状態切り替え
     */
    fun toggleTodoComplete(id: Int) {
        viewModelScope.launch {
            try {
                toggleTodoCompleteUseCase(id)
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(errorMessage = "更新に失敗しました: ${e.message}")
                }
            }
        }
    }
    
    /**
     * ToDoの削除
     */
    fun deleteTodo(id: Int) {
        viewModelScope.launch {
            try {
                deleteTodoUseCase(id)
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(errorMessage = "削除に失敗しました: ${e.message}")
                }
            }
        }
    }
    
    /**
     * エラーメッセージのクリア
     */
    fun clearError() {
        _uiState.update { it.copy(errorMessage = null) }
    }
}

4.3.4 UI State Modelの移動

ファイル: app/src/main/java/com/example/todoapp/presentation/model/TodoUiState.kt

package com.example.todoapp.presentation.model

import com.example.todoapp.domain.model.TodoItem

/**
 * ToDoリスト画面のUI状態
 */
data class TodoUiState(
    val todos: List<TodoItem> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

4.4 テスト戦略

4.4.1 テストの種類

1. Unit Test:

  • 個々のクラスやメソッドのテスト
  • 実行が高速
  • 依存関係をモック化

2. Integration Test:

  • 複数のコンポーネントの統合テスト
  • Repository + DAO など

3. UI Test (Instrumentation Test):

  • 実際のデバイスで実行
  • ユーザー操作をシミュレート

テストピラミッド:

       ┌─────────┐
       │UI Test  │  少ない(遅い、コスト高)
       ├─────────┤
       │ Integra │
       │tion Test│  中程度
       ├─────────┤
       │  Unit   │
       │  Test   │  多い(速い、コスト低)
       └─────────┘

4.4.2 テスト用の依存関係

ファイル: build.gradle.kts (Module: app)

dependencies {
    // 既存の依存関係...
    
    // Testing
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
    testImplementation("app.cash.turbine:turbine:1.0.0")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("com.google.truth:truth:1.1.5")
    
    // Android Testing
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    androidTestImplementation("io.mockk:mockk-android:1.13.8")
    
    // Room Testing
    testImplementation("androidx.room:room-testing:2.6.1")
    
    // Debug
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

4.5 ユニットテスト

4.5.1 UseCaseのテスト

ファイル: app/src/test/java/com/example/todoapp/domain/usecase/AddTodoUseCaseTest.kt

package com.example.todoapp.domain.usecase

import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.domain.repository.TodoRepository
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test

class AddTodoUseCaseTest {
    
    private lateinit var repository: TodoRepository
    private lateinit var useCase: AddTodoUseCase
    
    @Before
    fun setup() {
        repository = mockk()
        useCase = AddTodoUseCase(repository)
    }
    
    @Test
    fun `正常なToDoの追加が成功すること`() = runTest {
        // Given
        val title = "テストToDo"
        val description = "テスト説明"
        val expectedId = 1L
        
        coEvery {
            repository.insertTodo(any())
        } returns expectedId
        
        // When
        val result = useCase(
            AddTodoUseCase.Params(
                title = title,
                description = description
            )
        )
        
        // Then
        assertThat(result).isEqualTo(expectedId)
        coVerify {
            repository.insertTodo(
                match { todo ->
                    todo.title == title &&
                    todo.description == description &&
                    !todo.isCompleted
                }
            )
        }
    }
    
    @Test(expected = IllegalArgumentException::class)
    fun `空のタイトルでエラが発生すること`() = runTest {
        // Given
        val title = ""
        val description = "テスト説明"
        
        // When & Then
        useCase(
            AddTodoUseCase.Params(
                title = title,
                description = description
            )
        )
    }
    
    @Test(expected = IllegalArgumentException::class)
    fun `タイトルが100文字を超えるとエラが発生すること`() = runTest {
        // Given
        val title = "あ".repeat(101)
        val description = "テスト説明"
        
        // When & Then
        useCase(
            AddTodoUseCase.Params(
                title = title,
                description = description
            )
        )
    }
    
    @Test
    fun `タイトルの前後の空白が削除されること`() = runTest {
        // Given
        val title = "  テストToDo  "
        val description = "  テスト説明  "
        val expectedId = 1L
        
        coEvery {
            repository.insertTodo(any())
        } returns expectedId
        
        // When
        useCase(
            AddTodoUseCase.Params(
                title = title,
                description = description
            )
        )
        
        // Then
        coVerify {
            repository.insertTodo(
                match { todo ->
                    todo.title == "テストToDo" &&
                    todo.description == "テスト説明"
                }
            )
        }
    }
}

4.5.2 ViewModelのテスト

ファイル: app/src/test/java/com/example/todoapp/presentation/TodoViewModelTest.kt

package com.example.todoapp.presentation

import app.cash.turbine.test
import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.domain.usecase.*
import com.example.todoapp.presentation.model.TodoUiState
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Before
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class TodoViewModelTest {
    
    private val testDispatcher = StandardTestDispatcher()
    
    private lateinit var getAllTodosUseCase: GetAllTodosUseCase
    private lateinit var addTodoUseCase: AddTodoUseCase
    private lateinit var deleteTodoUseCase: DeleteTodoUseCase
    private lateinit var toggleTodoCompleteUseCase: ToggleTodoCompleteUseCase
    
    private lateinit var viewModel: TodoViewModel
    
    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        
        getAllTodosUseCase = mockk()
        addTodoUseCase = mockk(relaxed = true)
        deleteTodoUseCase = mockk(relaxed = true)
        toggleTodoCompleteUseCase = mockk(relaxed = true)
    }
    
    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
    
    @Test
    fun `初期化時にToDoリストが読み込まれること`() = runTest {
        // Given
        val testTodos = listOf(
            TodoItem(1, "ToDo 1", "Description 1"),
            TodoItem(2, "ToDo 2", "Description 2")
        )
        
        coEvery { getAllTodosUseCase() } returns flowOf(testTodos)
        
        // When
        viewModel = TodoViewModel(
            getAllTodosUseCase,
            addTodoUseCase,
            deleteTodoUseCase,
            toggleTodoCompleteUseCase
        )
        
        advanceUntilIdle()
        
        // Then
        viewModel.uiState.test {
            val state = awaitItem()
            assertThat(state.todos).isEqualTo(testTodos)
            assertThat(state.isLoading).isFalse()
            assertThat(state.errorMessage).isNull()
        }
    }
    
    @Test
    fun `ToDoの追加が成功すること`() = runTest {
        // Given
        coEvery { getAllTodosUseCase() } returns flowOf(emptyList())
        coEvery {
            addTodoUseCase(any())
        } returns 1L
        
        viewModel = TodoViewModel(
            getAllTodosUseCase,
            addTodoUseCase,
            deleteTodoUseCase,
            toggleTodoCompleteUseCase
        )
        
        advanceUntilIdle()
        
        // When
        viewModel.addTodo("新しいToDo", "説明")
        advanceUntilIdle()
        
        // Then
        coVerify {
            addTodoUseCase(
                AddTodoUseCase.Params(
                    title = "新しいToDo",
                    description = "説明"
                )
            )
        }
    }
    
    @Test
    fun `ToDoの削除が成功すること`() = runTest {
        // Given
        coEvery { getAllTodosUseCase() } returns flowOf(emptyList())
        
        viewModel = TodoViewModel(
            getAllTodosUseCase,
            addTodoUseCase,
            deleteTodoUseCase,
            toggleTodoCompleteUseCase
        )
        
        advanceUntilIdle()
        
        // When
        viewModel.deleteTodo(1)
        advanceUntilIdle()
        
        // Then
        coVerify { deleteTodoUseCase(1) }
    }
    
    @Test
    fun `ToDoの完了状態切り替えが成功すること`() = runTest {
        // Given
        coEvery { getAllTodosUseCase() } returns flowOf(emptyList())
        
        viewModel = TodoViewModel(
            getAllTodosUseCase,
            addTodoUseCase,
            deleteTodoUseCase,
            toggleTodoCompleteUseCase
        )
        
        advanceUntilIdle()
        
        // When
        viewModel.toggleTodoComplete(1)
        advanceUntilIdle()
        
        // Then
        coVerify { toggleTodoCompleteUseCase(1) }
    }
    
    @Test
    fun `エラ発生時にエラメッセジが設定されること`() = runTest {
        // Given
        val errorMessage = "エラーが発生しました"
        coEvery { getAllTodosUseCase() } throws Exception(errorMessage)
        
        // When
        viewModel = TodoViewModel(
            getAllTodosUseCase,
            addTodoUseCase,
            deleteTodoUseCase,
            toggleTodoCompleteUseCase
        )
        
        advanceUntilIdle()
        
        // Then
        viewModel.uiState.test {
            val state = awaitItem()
            assertThat(state.errorMessage).contains(errorMessage)
            assertThat(state.isLoading).isFalse()
        }
    }
}

4.5.3 Repositoryのテスト

ファイル: app/src/test/java/com/example/todoapp/data/repository/TodoRepositoryImplTest.kt

package com.example.todoapp.data.repository

import app.cash.turbine.test
import com.example.todoapp.data.local.TodoDao
import com.example.todoapp.data.local.TodoEntity
import com.example.todoapp.domain.model.TodoItem
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test

class TodoRepositoryImplTest {
    
    private lateinit var todoDao: TodoDao
    private lateinit var repository: TodoRepositoryImpl
    
    @Before
    fun setup() {
        todoDao = mockk()
        repository = TodoRepositoryImpl(todoDao)
    }
    
    @Test
    fun `全てのToDoが正しく取得できること`() = runTest {
        // Given
        val entities = listOf(
            TodoEntity(1, "ToDo 1", "Description 1", false, 0L, 0L),
            TodoEntity(2, "ToDo 2", "Description 2", true, 0L, 0L)
        )
        
        coEvery { todoDao.getAllTodos() } returns flowOf(entities)
        
        // When & Then
        repository.getAllTodos().test {
            val items = awaitItem()
            assertThat(items).hasSize(2)
            assertThat(items[0].id).isEqualTo(1)
            assertThat(items[0].title).isEqualTo("ToDo 1")
            assertThat(items[1].isCompleted).isTrue()
            awaitComplete()
        }
    }
    
    @Test
    fun `ToDoが正しく挿入されること`() = runTest {
        // Given
        val todo = TodoItem(0, "新しいToDo", "説明", false)
        coEvery { todoDao.insertTodo(any()) } returns 1L
        
        // When
        val result = repository.insertTodo(todo)
        
        // Then
        assertThat(result).isEqualTo(1L)
        coVerify {
            todoDao.insertTodo(
                match { entity ->
                    entity.title == "新しいToDo" &&
                    entity.description == "説明" &&
                    !entity.isCompleted
                }
            )
        }
    }
    
    @Test
    fun `ToDoが正しく削除されること`() = runTest {
        // Given
        val todoId = 1
        coEvery { todoDao.deleteTodoById(todoId) } returns Unit
        
        // When
        repository.deleteTodo(todoId)
        
        // Then
        coVerify { todoDao.deleteTodoById(todoId) }
    }
    
    @Test
    fun `完了状態が正しく切り替わること`() = runTest {
        // Given
        val todoId = 1
        val entity = TodoEntity(
            id = todoId,
            title = "ToDo",
            description = "Description",
            isCompleted = false,
            createdAt = 0L,
            updatedAt = 0L
        )
        
        coEvery { todoDao.getTodoById(todoId) } returns entity
        coEvery { todoDao.updateTodo(any()) } returns Unit
        
        // When
        repository.toggleTodoComplete(todoId)
        
        // Then
        coVerify {
            todoDao.updateTodo(
                match { it.isCompleted == true }
            )
        }
    }
}

4.6 UIテスト

4.6.1 Compose UI Test の基本

ファイル: app/src/androidTest/java/com/example/todoapp/TodoScreenTest.kt

package com.example.todoapp

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.todoapp.domain.model.TodoItem
import com.example.todoapp.presentation.TodoScreen
import com.example.todoapp.presentation.components.TodoItemCard
import com.example.todoapp.ui.theme.TodoAppTheme
import org.junit.Rule
import org.junit.Test

class TodoScreenTest {
    
    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun todoItemCard_displaysCorrectly() {
        // Given
        val todo = TodoItem(
            id = 1,
            title = "テストToDo",
            description = "テスト説明",
            isCompleted = false
        )
        
        // When
        composeTestRule.setContent {
            TodoAppTheme {
                TodoItemCard(
                    todoItem = todo,
                    onToggleComplete = {},
                    onDelete = {}
                )
            }
        }
        
        // Then
        composeTestRule
            .onNodeWithText("テストToDo")
            .assertIsDisplayed()
        
        composeTestRule
            .onNodeWithText("テスト説明")
            .assertIsDisplayed()
    }
    
    @Test
    fun todoItemCard_checkboxWorks() {
        // Given
        val todo = TodoItem(
            id = 1,
            title = "テストToDo",
            description = "",
            isCompleted = false
        )
        
        var toggledId: Int? = null
        
        // When
        composeTestRule.setContent {
            TodoAppTheme {
                TodoItemCard(
                    todoItem = todo,
                    onToggleComplete = { toggledId = it },
                    onDelete = {}
                )
            }
        }
        
        // Then
        composeTestRule
            .onNode(hasContentDescription(""))
            .performClick()
        
        assert(toggledId == 1)
    }
    
    @Test
    fun todoItemCard_deleteButtonWorks() {
        // Given
        val todo = TodoItem(
            id = 1,
            title = "テストToDo",
            description = "",
            isCompleted = false
        )
        
        var deletedId: Int? = null
        
        // When
        composeTestRule.setContent {
            TodoAppTheme {
                TodoItemCard(
                    todoItem = todo,
                    onToggleComplete = {},
                    onDelete = { deletedId = it }
                )
            }
        }
        
        // Then
        composeTestRule
            .onNodeWithContentDescription("削除")
            .performClick()
        
        assert(deletedId == 1)
    }
    
    @Test
    fun addTodoDialog_validationWorks() {
        // When
        composeTestRule.setContent {
            TodoAppTheme {
                TodoScreen()
            }
        }
        
        // FABをクリックしてダイアログを開く
        composeTestRule
            .onNodeWithContentDescription("追加")
            .performClick()
        
        // 追加ボタンが無効であることを確認
        composeTestRule
            .onNodeWithText("追加")
            .assertIsNotEnabled()
        
        // タイトルを入力
        composeTestRule
            .onNodeWithText("タイトル")
            .performTextInput("新しいToDo")
        
        // 追加ボタンが有効になることを確認
        composeTestRule
            .onNodeWithText("追加")
            .assertIsEnabled()
    }
}

4.6.2 テスト実行

1. Unit Testの実行:

# すべてのユニットテストを実行
./gradlew test

# 特定のテストクラスを実行
./gradlew test --tests TodoViewModelTest

# レポートの確認
# app/build/reports/tests/testDebugUnitTest/index.html

Android Studioから実行:

  1. テストファイルを右クリック
  2. 「Run 'TestClassName'」を選択

2. UI Testの実行:

# すべてのInstrumentation Testを実行
./gradlew connectedAndroidTest

# レポートの確認
# app/build/reports/androidTests/connected/index.html

Android Studioから実行:

  1. エミュレータまたは実機を接続
  2. テストファイルを右クリック
  3. 「Run 'TestClassName'」を選択

4.6.3 テストカバレッジの確認

JaCoCo設定:

build.gradle.kts (Module: app):

android {
    // 既存の設定...
    
    buildTypes {
        debug {
            enableUnitTestCoverage = true
            enableAndroidTestCoverage = true
        }
    }
}

カバレッジレポートの生成:

# カバレッジレポートを生成
./gradlew createDebugCoverageReport

# レポートの確認
# app/build/reports/coverage/androidTest/debug/index.html

🎉 Part 4完了!
Clean Architectureの実装とテストができました。最後にCI/CDを設定してリリース準備を整えます。


Part 5: CI/CD・リリース編(上級者向け)

⏱️ 学習時間の目安: 4〜6時間

5.1 GitHub Actionsの設定

5.1.1 GitHub Actionsとは

GitHub Actionsは、GitHubが提供するCI/CDプラットフォームです。

メリット:

  • GitHubに統合されている
  • 無料枠が充実(Public: 無制限、Private: 2,000分/月)
  • 豊富なアクション(再利用可能なワークフロー)
  • マトリックスビルド対応

5.1.2 ワークフローディレクトリの作成

プロジェクトルートに.github/workflowsディレクトリを作成します。

mkdir -p .github/workflows

5.1.3 基本的なビルドワークフロー

ファイル: .github/workflows/android-build.yml

name: Android Build

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Build with Gradle
      run: ./gradlew build
    
    - name: Upload build artifacts
      uses: actions/upload-artifact@v4
      if: success()
      with:
        name: build-artifacts
        path: app/build/outputs/
        retention-days: 7

5.2 自動ビルド・テスト

5.2.1 テスト実行ワークフロー

ファイル: .github/workflows/android-test.yml

name: Android Test

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  unit-test:
    name: Unit Tests
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Run unit tests
      run: ./gradlew test
    
    - name: Generate test report
      uses: dorny/test-reporter@v1
      if: always()
      with:
        name: Unit Test Results
        path: '**/build/test-results/test*/TEST-*.xml'
        reporter: java-junit
    
    - name: Upload test results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: unit-test-results
        path: |
          **/build/test-results/
          **/build/reports/tests/
        retention-days: 7

  instrumentation-test:
    name: Instrumentation Tests
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        api-level: [30, 33, 34]
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Enable KVM group perms
      run: |
        echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
        sudo udevadm control --reload-rules
        sudo udevadm trigger --name-match=kvm
    
    - name: Run instrumentation tests
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: ${{ matrix.api-level }}
        arch: x86_64
        target: google_apis
        script: ./gradlew connectedAndroidTest
    
    - name: Upload test results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: instrumentation-test-results-api-${{ matrix.api-level }}
        path: |
          **/build/reports/androidTests/
          **/build/outputs/androidTest-results/
        retention-days: 7

5.2.2 テストカバレッジワークフロー

ファイル: .github/workflows/test-coverage.yml

name: Test Coverage

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  coverage:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Run tests with coverage
      run: ./gradlew createDebugCoverageReport
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v4
      with:
        files: ./app/build/reports/coverage/androidTest/debug/report.xml
        flags: unittests
        name: codecov-umbrella
        fail_ci_if_error: true
    
    - name: Generate coverage report
      run: ./gradlew jacocoTestReport
    
    - name: Upload coverage reports
      uses: actions/upload-artifact@v4
      with:
        name: coverage-reports
        path: |
          **/build/reports/coverage/
          **/build/reports/jacoco/
        retention-days: 7

5.3 コード品質チェック

5.3.1 Lintチェックワークフロー

ファイル: .github/workflows/code-quality.yml

name: Code Quality

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  lint:
    name: Lint Check
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Run Android Lint
      run: ./gradlew lint
    
    - name: Upload lint results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: lint-results
        path: |
          **/build/reports/lint-results*.html
          **/build/reports/lint-results*.xml
        retention-days: 7
    
    - name: Annotate lint results
      uses: yutailang0119/action-android-lint@v3
      if: always()
      with:
        report-path: '**/build/reports/lint-results*.xml'
        
  ktlint:
    name: Kotlin Lint
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Run ktlint
      run: ./gradlew ktlintCheck
    
    - name: Upload ktlint results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: ktlint-results
        path: '**/build/reports/ktlint/'
        retention-days: 7

  detekt:
    name: Detekt
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Run detekt
      run: ./gradlew detekt
    
    - name: Upload detekt results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: detekt-results
        path: '**/build/reports/detekt/'
        retention-days: 7

5.3.2 ktlintとdetektの設定

ktlintの追加 - build.gradle.kts (Module: app):

plugins {
    // 既存のプラグイン...
    id("org.jlleitschuh.gradle.ktlint") version "12.0.3"
}

ktlint {
    android.set(true)
    ignoreFailures.set(false)
    reporters {
        reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN)
        reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
        reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.HTML)
    }
}

detektの追加 - build.gradle.kts (Module: app):

plugins {
    // 既存のプラグイン...
    id("io.gitlab.arturbosch.detekt") version "1.23.4"
}

detekt {
    buildUponDefaultConfig = true
    allRules = false
    config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
}

dependencies {
    detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.4")
}

detekt設定ファイル: config/detekt/detekt.yml

build:
  maxIssues: 0

complexity:
  LongMethod:
    threshold: 40
  LongParameterList:
    functionThreshold: 6

style:
  MagicNumber:
    ignoreNumbers: [-1, 0, 1, 2]
  MaxLineLength:
    maxLineLength: 120

5.4 署名とリリースビルド

5.4.1 キーストアの生成

コマンドラインから生成:

keytool -genkey -v \
  -keystore my-release-key.jks \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000 \
  -alias my-key-alias

入力項目:

  • パスワード: 安全なパスワードを設定
  • 名前、組織名、都市名など: 適切に入力
  • エイリアスのパスワード: キーストアと同じでOK

重要: 生成したキーストアファイル(.jks)は安全に保管してください!

5.4.2 署名の設定

ファイル: keystore.properties (プロジェクトルート、Gitignore対象)

storePassword=your_keystore_password
keyPassword=your_key_password
keyAlias=my-key-alias
storeFile=../my-release-key.jks

.gitignoreに追加:

# Keystore files
*.jks
*.keystore
keystore.properties

build.gradle.kts (Module: app)の更新:

import java.util.Properties
import java.io.FileInputStream

android {
    // 既存の設定...
    
    signingConfigs {
        create("release") {
            val keystorePropertiesFile = rootProject.file("keystore.properties")
            if (keystorePropertiesFile.exists()) {
                val keystoreProperties = Properties()
                keystoreProperties.load(FileInputStream(keystorePropertiesFile))
                
                storeFile = file(keystoreProperties["storeFile"] as String)
                storePassword = keystoreProperties["storePassword"] as String
                keyAlias = keystoreProperties["keyAlias"] as String
                keyPassword = keystoreProperties["keyPassword"] as String
            }
        }
    }
    
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.getByName("release")
        }
        
        debug {
            applicationIdSuffix = ".debug"
            versionNameSuffix = "-DEBUG"
        }
    }
}

5.4.3 GitHub Secretsの設定

CI/CDで署名するために、GitHub Secretsに認証情報を保存します。

1. キーストアをBase64エンコード:

base64 my-release-key.jks > keystore-base64.txt

2. GitHub Secretsに登録:

GitHubリポジトリで:

  1. Settings → Secrets and variables → Actions
  2. "New repository secret" をクリック
  3. 以下のSecretを追加:
    • KEYSTORE_BASE64: エンコードしたキーストア
    • KEYSTORE_PASSWORD: キーストアのパスワード
    • KEY_ALIAS: キーのエイリアス
    • KEY_PASSWORD: キーのパスワード

5.4.4 リリースビルドワークフロー

ファイル: .github/workflows/release-build.yml

name: Release Build

on:
  push:
    tags:
      - 'v*.*.*'

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    
    - name: Decode keystore
      run: |
        echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > my-release-key.jks
    
    - name: Create keystore.properties
      run: |
        echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" >> keystore.properties
        echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> keystore.properties
        echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> keystore.properties
        echo "storeFile=../my-release-key.jks" >> keystore.properties
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Build release APK
      run: ./gradlew assembleRelease
    
    - name: Build release AAB
      run: ./gradlew bundleRelease
    
    - name: Sign APK
      uses: r0adkll/sign-android-release@v1
      with:
        releaseDirectory: app/build/outputs/apk/release
        signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
        alias: ${{ secrets.KEY_ALIAS }}
        keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
        keyPassword: ${{ secrets.KEY_PASSWORD }}
    
    - name: Upload APK
      uses: actions/upload-artifact@v4
      with:
        name: release-apk
        path: app/build/outputs/apk/release/*.apk
        retention-days: 30
    
    - name: Upload AAB
      uses: actions/upload-artifact@v4
      with:
        name: release-aab
        path: app/build/outputs/bundle/release/*.aab
        retention-days: 30
    
    - name: Create GitHub Release
      uses: softprops/action-gh-release@v1
      with:
        files: |
          app/build/outputs/apk/release/*.apk
          app/build/outputs/bundle/release/*.aab
        generate_release_notes: true
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5.5 難読化(ProGuard/R8)

5.5.1 ProGuardとR8の違い

R8:

  • Android Gradle Plugin 3.4以降のデフォルト
  • ProGuardより高速
  • コード圧縮、難読化、最適化を実行

ProGuard:

  • レガシーツール
  • より細かい設定が可能

5.5.2 ProGuardルールの設定

ファイル: app/proguard-rules.pro

# Android基本設定
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keepattributes Signature
-keepattributes Exceptions

# Kotlin
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
-dontwarn kotlin.**
-keepclassmembers class **$WhenMappings {
    <fields>;
}
-keepclassmembers class kotlin.Metadata {
    public <methods>;
}

# Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembernames class kotlinx.** {
    volatile <fields>;
}

# Jetpack Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
-keepclassmembers class androidx.compose.** {
    *;
}

# ViewModel
-keep class * extends androidx.lifecycle.ViewModel {
    <init>();
}
-keep class * extends androidx.lifecycle.AndroidViewModel {
    <init>(android.app.Application);
}

# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**

# Hilt
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel {
    <init>(...);
}
-keepclasseswithmembers class * {
    @dagger.* <methods>;
}
-keepclasseswithmembers class * {
    @javax.inject.* <methods>;
}

# データクラス (必要に応じて)
-keep class com.example.todoapp.domain.model.** { *; }
-keep class com.example.todoapp.data.local.** { *; }

# Parcelable
-keepclassmembers class * implements android.os.Parcelable {
    public static final ** CREATOR;
}

# Serializable
-keepclassmembers class * implements java.io.Serializable {
    static final long serialVersionUID;
    private static final java.io.ObjectStreamField[] serialPersistentFields;
    private void writeObject(java.io.ObjectOutputStream);
    private void readObject(java.io.ObjectInputStream);
    java.lang.Object writeReplace();
    java.lang.Object readResolve();
}

# Enum
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# デバッグ用 (リリース時は削除推奨)
-printmapping mapping.txt
-printseeds seeds.txt
-printusage usage.txt

# クラッシュレポート用
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

5.5.3 R8の最適化設定

build.gradle.kts (Module: app):

android {
    buildTypes {
        release {
            // コード圧縮を有効化
            isMinifyEnabled = true
            
            // 未使用リソースの削除
            isShrinkResources = true
            
            // ProGuardファイルの指定
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            
            // 署名設定
            signingConfig = signingConfigs.getByName("release")
        }
        
        create("benchmark") {
            initWith(getByName("release"))
            matchingFallbacks.add("release")
            isDebuggable = false
        }
    }
}

5.5.4 難読化の検証

1. マッピングファイルの確認:

リリースビルド後、app/build/outputs/mapping/release/mapping.txtが生成されます。

com.example.todoapp.MainActivity -> a.a.a:
    void onCreate(android.os.Bundle) -> onCreate
com.example.todoapp.domain.model.TodoItem -> a.b.c:
    int id -> a
    java.lang.String title -> b

2. APK Analyzerでサイズ確認:

Android Studioで:

Build → Analyze APK → 生成されたAPKを選択
  • ファイルサイズの確認
  • DEXファイルの内容確認
  • リソースの使用状況確認

3. クラッシュレポートのデコード:

ProGuardで難読化したアプリがクラッシュした場合、mapping.txtを使ってスタックトレースをデコードします。

# ReTraceツールを使用
retrace mapping.txt crash-stack-trace.txt

5.5.5 リリース前チェックリスト

1. コード品質:

  • Lintエラーがない
  • テストがすべてパス
  • コードレビュー完了

2. ビルド設定:

  • versionCodeを更新
  • versionNameを更新
  • applicationIdが正しい
  • 署名設定が正しい

3. 難読化:

  • ProGuardルールが適切
  • マッピングファイルを保存
  • クラッシュが発生しないか確認

4. パフォーマンス:

  • APKサイズが適切
  • 起動時間が許容範囲
  • メモリリークがない

5. セキュリティ:

  • APIキーがハードコードされていない
  • デバッグログが無効
  • 署名が正しい

まとめ

本記事では、Android Studioの基本的な使い方から、モダンなAndroidアプリ開発の実践まで、段階的に解説しました。

📦 サンプルコード

本記事で解説したコードの完全版は、GitHubリポジトリで公開予定です。
実際に動作するコードを参考にしながら学習を進めてください。

学んだ内容

Part 1: 環境構築編

  • Android Studioのインストールと初期設定
  • エミュレータの設定
  • 画面構成と基本操作
  • 便利なショートカット

Part 2: プロジェクト作成とUI基礎編

  • プロジェクトの作成とディレクトリ構造
  • Jetpack Composeの基礎
  • 基本的なUI実装
  • プレビュー機能とデバッグ

Part 3: MVVM実装編

  • MVVMアーキテクチャの理解
  • ViewModelの実装
  • Room Databaseの導入
  • Repository層の実装
  • Hiltによる依存性注入
  • Coroutines/Flowの活用

Part 4: Clean Architecture実装編

  • Clean Architectureの理解
  • レイヤー分離の実装
  • UseCaseの実装
  • ユニットテストの作成
  • UIテストの作成

Part 5: CI/CD・リリース編

  • GitHub Actionsの設定
  • 自動ビルド・テスト
  • コード品質チェック
  • 署名とリリースビルド
  • ProGuard/R8による難読化

次のステップ

さらに学びを深めるために:

1. 機能追加:

  • カテゴリ機能
  • 検索機能
  • ソート・フィルター
  • リマインダー機能
  • バックアップ・リストア

2. UI/UX改善:

  • Material Design 3の活用
  • アニメーション
  • ダークモード対応
  • 多言語対応

3. データ連携:

  • Firebase連携
  • REST API連携
  • オフライン対応
  • 同期機能

4. 高度な機能:

  • Widget対応
  • 通知機能
  • カメラ・ギャラリー連携
  • 位置情報の活用

5. パフォーマンス最適化:

  • LazyColumn最適化
  • 画像の最適化
  • バッテリー消費の改善
  • ネットワーク最適化

参考リソース

公式ドキュメント:

学習リソース:


最後に

Android開発は常に進化しています。本記事で学んだ基礎をベースに、継続的に新しい技術やベストプラクティスをキャッチアップしていきましょう。

重要なポイント:

  1. 段階的に学ぶ: 焦らず、一つずつ理解を深める
  2. 実際に作る: 読むだけでなく、手を動かして学ぶ
  3. テストを書く: テストを通じてコードの品質を保つ
  4. コミュニティに参加: 他の開発者と交流し、知識を共有
  5. 継続的な学習: 新しい技術やベストプラクティスを学び続ける

皆さんのAndroid開発の旅が実りあるものになることを願っています!

何か質問や改善点があれば、コメント欄でお気軽にお知らせください。


よくあるトラブルシューティング

ビルドエラー関連

Q1: "Unresolved reference" エラーが出る

A: Gradle同期を実行してください
- File → Sync Project with Gradle Files
- または、File → Invalidate Caches → Invalidate and Restart

Q2: "Could not find method implementation()" エラー

A: Gradleのバージョンを確認してください
- gradle-wrapper.propertiesで最新バージョンを指定
- Android Gradle Pluginのバージョンも確認

Q3: Hiltで "@HiltAndroidApp annotation is missing" エラー

A: TodoApplicationクラスを作成し、AndroidManifest.xmlで指定してください

エミュレータ関連

Q1: エミュレータが起動しない

A: 以下を確認してください
- BIOSでVT-x/AMD-Vが有効か
- HAXMがインストールされているか (Windows/Mac)
- 十分なメモリが割り当てられているか

Q2: エミュレータの動作が遅い

A: 以下を試してください
- Graphics設定を"Hardware - GLES 2.0"に変更
- RAMを増やす (2GB以上推奨)
- ホストマシンのリソースを確認

Room Database関連

Q1: "Cannot access database on the main thread" エラー

A: データベース操作は必ずCoroutineで実行してください
viewModelScope.launch { repository.getData() }

Q2: マイグレーションエラー

A: 開発中は fallbackToDestructiveMigration() を使用
本番環境では適切なMigrationを実装してください

Compose関連

Q1: プレビューが表示されない

A: 以下を確認してください
- @Previewアノテーションが付いているか
- 関数名がPascalCaseか
- パラメータにデフォルト値が設定されているか

Q2: "Composable invocations can only happen from the context of a @Composable function" エラー

A: @Composableアノテーションを付けてください
または、LaunchedEffectなど適切なComposable内で呼び出してください

タグ: #Android #AndroidStudio #Kotlin #JetpackCompose #MVVM #CleanArchitecture #CI/CD #GitHubActions


免責事項

本記事の内容は、2024年11月時点の情報に基づいています。Android開発のツールやライブラリは頻繁に更新されるため、最新の情報は公式ドキュメントをご確認ください。また、本記事のサンプルコードは学習目的で作成されており、本番環境で使用する際は適切なエラーハンドリングやセキュリティ対策を追加してください。

謝辞

本記事の作成にあたり、Android公式ドキュメント、Kotlinドキュメント、および多くの開発者コミュニティの知見を参考にさせていただきました。


この記事が参考になった方は、いいね・ストックをお願いします!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?