📌 はじめに
本記事では、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/androidとhttp://schemas.android.com/toolsを使用します(Qiita投稿制限のため省略表記)
⏱️ 全体の学習時間: 約24〜33時間(初心者が最初から最後まで学習する場合)
目次
Part 1: 環境構築編(初心者向け)
Part 2: プロジェクト作成とUI基礎編(初心者〜中級者向け)
Part 3: MVVM実装編(中級者向け)
Part 4: Clean Architecture実装編(上級者向け)
Part 5: CI/CD・リリース編(上級者向け)
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:
- ダウンロードした
.exeファイルを実行 - インストールウィザードに従って進める
- 「Android Virtual Device」にチェックを入れる
- インストール先を指定(デフォルト推奨)
- インストール完了後、「Start Android Studio」をチェックして起動
macOS:
- ダウンロードした
.dmgファイルを開く - Android Studioアイコンをアプリケーションフォルダにドラッグ
- アプリケーションフォルダから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のインストール
推奨設定:
-
SDK Managerを開く
-
「SDK Platforms」タブを選択
-
以下をチェック:
- ✅ 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(エミュレータ用)
-
「Apply」をクリック
-
ライセンス同意後、「OK」をクリック
-
ダウンロード完了を待つ
1.2.3 SDK Toolsのインストール
-
SDK Managerで「SDK Tools」タブを選択
-
「Show Package Details」をチェック
-
以下をチェック:
- ✅ 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
-
「Apply」→「OK」をクリック
-
インストール完了を待つ
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: デバイスの選択
- 「Create Device」をクリック
- カテゴリから「Phone」を選択
- デバイスを選択(推奨: Pixel 6)
- 画面サイズ、解像度、密度が表示される
- 「Next」をクリック
Step 2: システムイメージの選択
- 「Recommended」タブから選択(推奨)
- API Level 34 (Android 14.0)を選択
- 「Download」をクリックしてダウンロード(初回のみ)
- 「Next」をクリック
Step 3: AVDの設定
- AVD Name: 例「Pixel_6_API_34」
- Startup orientation: Portrait(縦向き)
- 「Show Advanced Settings」をクリック(詳細設定の場合)
- RAM: 2048MB以上推奨
- VM heap: 512MB
- Internal Storage: 2048MB以上
- SD card: 512MB(必要に応じて)
- Graphics: Automatic(推奨)、またはHardware
- 「Finish」をクリック
1.3.3 エミュレータの起動
方法1: Device Managerから起動
- Device Managerを開く
- 作成したAVDの「▶」(再生)ボタンをクリック
方法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設定:
- AVD設定を開く(Device Managerで「編集」アイコン)
- 「Show Advanced Settings」
- 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: テンプレートの選択
- 「Phone and Tablet」タブを選択
- 「Empty Activity」を選択
- Jetpack Composeを使用する最もシンプルなテンプレート
- 「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"
解決策:
- インターネット接続を確認
- Gradleの再同期を試す
File → Sync Project with Gradle Files - 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設計
画面構成:
- TopAppBar: タイトル表示
- LazyColumn: ToDoリスト表示
- FloatingActionButton: 新規追加ボタン
- 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 = {}
)
}
}
プレビューの表示:
- Composable関数に
@Previewを追加 - エディタの右側に「Split」または「Design」タブが表示される
- プレビューが自動的に表示される
複数のプレビュー:
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のフィルタリング:
- パッケージフィルタ: 自分のアプリのログのみ表示
- タグフィルタ: 特定のTAGでフィルタ
- レベルフィルタ: ログレベルでフィルタ
- 検索: キーワードで検索
正規表現フィルタ:
tag:MainActivity
package:com.example.todoapp
level:ERROR
2.5.3 デバッグ実行
ブレークポイントの設定:
- コードの行番号の左側をクリック
- 赤い丸が表示される
デバッグ実行:
ツールバー: 🐛 (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のメリット
- テストしやすい: 各層を独立してテスト可能
- 保守性が高い: 責務が明確で変更の影響範囲が限定的
- 再利用性: ViewModelやRepositoryを複数の画面で共有可能
- 設定変更に強い: 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のメリット
- テストしやすい: 各層を独立してテスト可能
- 保守性が高い: 責務が明確
- 技術的負債の軽減: フレームワーク変更の影響が限定的
- 並行開発が容易: 層ごとに開発可能
- 再利用性: 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.ktをdomain/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 既存ファイルの移動
以下のファイルを適切なパッケージに移動:
-
Repository Interface:
domain/repository/に移動 -
Repository Implementation:
data/repository/に移動 -
Database関連:
data/local/に移動 -
Mapper:
data/mapper/に移動 -
ViewModel:
presentation/に移動
ファイル移動の手順:
- Android Studioでファイルを選択
- 右クリック → Refactor → Move
- 新しいパッケージを指定
- 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から実行:
- テストファイルを右クリック
- 「Run 'TestClassName'」を選択
2. UI Testの実行:
# すべてのInstrumentation Testを実行
./gradlew connectedAndroidTest
# レポートの確認
# app/build/reports/androidTests/connected/index.html
Android Studioから実行:
- エミュレータまたは実機を接続
- テストファイルを右クリック
- 「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リポジトリで:
- Settings → Secrets and variables → Actions
- "New repository secret" をクリック
- 以下の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開発は常に進化しています。本記事で学んだ基礎をベースに、継続的に新しい技術やベストプラクティスをキャッチアップしていきましょう。
重要なポイント:
- 段階的に学ぶ: 焦らず、一つずつ理解を深める
- 実際に作る: 読むだけでなく、手を動かして学ぶ
- テストを書く: テストを通じてコードの品質を保つ
- コミュニティに参加: 他の開発者と交流し、知識を共有
- 継続的な学習: 新しい技術やベストプラクティスを学び続ける
皆さんの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ドキュメント、および多くの開発者コミュニティの知見を参考にさせていただきました。
この記事が参考になった方は、いいね・ストックをお願いします!