BLOG

ブログ

2025/12/22

AndroidでClean Architectureを実装してみた

この記事を書いた人 K.Y

まずはじめに

皆様こんにちは、エンジニアのK.Yです。実務ではAndroidアプリの保守・開発を行っています。

今回、昔入社した際に作成した簡易電卓アプリをClean Architecture構成へリファクタリングしてみました。理由はシンプルに、昔勉強はしていたものの実際にこのアーキテクチャが採用されているアプリに関わることが少なかったからです。

※ 本記事の内容は筆者の理解に基づく実装例です。
より良い設計の提案などありましたら、ぜひコメントでご指摘いただけると嬉しいです!

なぜClean Architectureなのか?

現在の実務では基本的にMVVM構成のアプリ開発に携わることが多いです。新規アプリを作成する際慣れているその構成にしがちで、Clean Architectureについて「一度理解したつもりだったけど、実際プロジェクトでは使えていない」現状です。

冒頭でも触れましたがちょうど入社直後に、簡易電卓アプリを勉強のため作成していました。中身を確認してみると、若かったこともあり1つのActivityにすべての処理が書いてあり、可読性や拡張性の低さに自分でも驚きました。当時は“動けば良い”という意識で作っていましたが、今改めて見ると、保守が非常にしづらい構造になっていました。

元の簡易電卓アプリの処理

まず最初にリファクタ前どうなっていたのかを確認していきます。

アプリとしては簡素なもので、数字のボタン、四則演算のボタン、式の表示部分、過去の計算履歴の表示ができるようになっています。


元の処理はMainActivityCalculatorScreen に、UIの描画・入力処理・計算ロジックがすべて詰め込まれており、いわゆる「1ファイル完結型」の実装になっています。(一部省略しています)

fun CalculatorScreen(calculator: Calculator = Calculator) {

 var displayText by rememberSaveable { mutableStateOf("0") }

 var firstOperand by rememberSaveable { mutableStateOf<String?>(null) }

 var pendingOperation by rememberSaveable { mutableStateOf<CalculatorOperation?>(null) }

 var shouldResetDisplay by rememberSaveable { mutableStateOf(false) }

 var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }

 var lastResult by rememberSaveable { mutableStateOf<String?>(null) }

 var historyText by rememberSaveable { mutableStateOf("") }

 ・・・・(省略)・・・・

 fun onClear() {  
  displayText = "0"  
  firstOperand = null  
  pendingOperation = null  
  shouldResetDisplay = false  
  errorMessage = null  
 }

 Column(
  text = stringResource(id = R.string.calculator_title),  
  style = MaterialTheme.typography.headlineMedium  
 ) {
  val digits = remember { listOf("1", "2", "3", "4", "5", "6", "7", "8", "9") }
  
  ・・・・(省略)・・・・

  CalculatorButton(  
   label = clearLabel,  
   modifier = Modifier.weight(1f).testTag("clearButton"),  
   onClick = { onClear() }  
   )
 }
}

ご覧いただくとお分かりのように、rememberSaveableで表示値・演算子・エラー状態などの UI ステート、電卓処理のイベントのハンドリングをすべて同じファイル/ Composable で管理しています。

課題点として以下が挙げられます。

  • UI とロジックが一体化:数字入力・演算子選択・イコール処理・クリア処理といったビジネスロジックを、Composable 内のローカル関数として直接定義し、UI から直接呼び出す構造で、「UI 層とロジック層が未分離」な状態。
  • 状態は Compose のローカルステートdisplayTexthistoryTextpendingOperation などの状態管理はすべて rememberSaveable によるローカル変数で行われ、状態管理や履歴表示など、画面ライフサイクルをまたいだ再利用やテストが難しい形になっている。
  • 新しい機能を追加しようとすると、CalculatorScreen の肥大化が避けられない。

動作は問題ないものの、UIとロジックが密結合していてテストしづらく、アプリを拡張しようとするとすぐに限界が見える構成です。

そこで今回は、これをClean Architecture構成にリファクタリングしてみます。

Clean Architectureのざっくりおさらい

「Clean Architecture」と検索するとトップにわかりやすい記事が出てきます。

参考:やさしいクリーンアーキテクチャ

引用元:The Clean Code Blog

このアーキテクチャで調べると必ずといっていいほど出てくるのが上の図になります。

  • 責務の分離:UI、ビジネスロジック、データ処理などの“役割”を明確に分ける考え方で、どの層が何を担当するかをはっきりさせます。
  • 依存方向は内向きであり、ビジネスロジックをUIから切り離すのがポイントです。外側の層(UIやデータベース)は、内側のルール(ドメイン)に依存しますが、逆にドメインは外の実装を知らないです。つまりルールはUIにもデータにも依存しません。

Clean Architecture は「関心の分離」を徹底する設計思想であり、Androidの場合、ざっくり次のように層を分けます。

  • Domain層:ビジネスロジック(UseCaseなど)
  • Data層:Repository実装やAPI通信
  • Presentation層:ViewModelUI
  • Entity:アプリの中核となるデータモデル

実装例

以下、リファクタ後の構成になります。

com.example.sampleProject

├─ presentation

│   └─ calculator

│       ├─ CalculatorViewModel.kt

│       ├─ CalculatorUiState.kt

| ├─ CalculatorScreen.kt

│       └─ CalculationHistoryEntry.kt

├─ domain

│   ├─ model

│   │   ├─ CalculatorOperation.kt

│   │   ├─ CalculationError.kt

│   │   └─ CalculationResult.kt

│   ├─ repository

│   │   └─ CalculatorRepository.kt

│   └─ usecase

│       └─ PerformCalculationUseCase.kt

├─ data

│   └─ repository

│       └─ CalculatorRepositoryImpl.kt

├─ di

│   └─ AppModule.kt

└─ MainActivity.kt

ファイル構成としてはこのようになり、以下のような分離を行なっています。

※ コードでは1ファイルっぽく記載していますが、package記載のあるものは実際は別ファイルです。

ドメイン/ユースケース層

package com.example.sampleProject.domain.model

/**
 * ドメイン層: エラーの種類もここで表現しておくことでプレゼンテーション層がAndroid固有の表現に引きずられない。
 */
enum class CalculationError {
    InvalidInput,
    DivisionByZero
}

package com.example.sampleProject.domain.model

/**
 * ドメイン層: 計算の成功・失敗を共通の型で返すことで、上位層が例外処理に依存せず振る舞いを切り替えられる。
 */
sealed interface CalculationResult {
    data class Success(val value: Double) : CalculationResult
    data class Error(val error: CalculationError) : CalculationResult
}

package com.example.sampleProject.domain.model

/**
 * ドメイン層: 計算処理で利用する演算種別をUIやデータ実装から独立して定義する。
 */
enum class CalculatorOperation(val symbol: String) {
    Add("+"),
    Subtract("-"),
    Multiply("×"),
    Divide("÷")
}

package com.example.sampleProject.domain.repository

/**
 * ドメイン層: 計算ロジックへの入り口をインターフェース化し、データ層の実装差し替えを容易にする。
 */
interface CalculatorRepository {
    fun calculate(
        firstInput: String,
        secondInput: String,
        operation: CalculatorOperation
    ): CalculationResult
}

package com.example.sampleProject.domain.usecase

/**
 * ユースケース: プレゼンテーション層からはこのクラスを介して計算を依頼し、ドメインルールが分からないようにする。
 */
class PerformCalculationUseCase(
    private val calculatorRepository: CalculatorRepository
) {
    operator fun invoke(
        firstInput: String,
        secondInput: String,
        operation: CalculatorOperation
    ): CalculationResult = calculatorRepository.calculate(firstInput, secondInput, operation)
}

プレゼンテーション層

package com.example.sampleProject.presentation.calculator

/**
 * プレゼンテーション層: Composeは常にこのクラスのインスタンスだけを参照して、表示内容やエラー、履歴などを決定する。
 */
data class CalculatorUiState(
    val displayText: String = "0",
    val error: CalculationError? = null,
    val lastResult: String? = null,
    val history: List<CalculationHistoryEntry> = emptyList()
)

/**
 * プレゼンテーション層: 履歴1件分を表現し、UIでの表示に必要な情報だけを保持する。
 */
data class CalculationHistoryEntry(
    val firstOperand: String,
    val secondOperand: String,
    val operation: CalculatorOperation,
    val result: String
) {
    fun toDisplayString(): String = "$firstOperand ${operation.symbol} $secondOperand = $result"
}

package com.example.sampleProject.presentation.calculator

/**
 * プレゼンテーション層: ViewModelでユースケースとやり取りし、UIが参照できる状態に落とし込む。
 */
class CalculatorViewModel(
    private val performCalculationUseCase: PerformCalculationUseCase
) : ViewModel() {
    private val _uiState = MutableStateFlow(CalculatorUiState())
    val uiState: StateFlow<CalculatorUiState> = _uiState.asStateFlow()
  ....省略

  fun onClear() {
        firstOperand = null
        pendingOperation = null
        shouldResetDisplay = false
        _uiState.value = CalculatorUiState()
    }
}

package com.example.sampleProject.presentation.calculator

@Composable
fun CalculatorApp() {
    MaterialTheme {
        Surface(modifier = Modifier.fillMaxSize()) {
            CalculatorScreen()
        }
    }
}

@Composable
fun CalculatorScreen(
    viewModel: CalculatorViewModel = viewModel(factory = CalculatorViewModelFactory)
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    CalculatorScreenContent(
        uiState = uiState,
        onDigitPressed = viewModel::onDigitPressed,
        onOperationSelected = viewModel::onOperationSelected,
        onEqualsPressed = viewModel::onEqualsPressed,
        onClear = viewModel::onClear
    )
}

@Composable
fun CalculatorScreenContent(
    uiState: CalculatorUiState,
    onDigitPressed: (String) -> Unit,
    onOperationSelected: (CalculatorOperation) -> Unit,
    onEqualsPressed: () -> Unit,
    onClear: () -> Unit,
    modifier: Modifier = Modifier
) { ... }

@Composable
private fun CalculatorHistorySection(
    lastResult: String?,
    history: List<CalculationHistoryEntry>
) { ... }

@Composable
private fun CalculatorDisplay(text: String) {
    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .testTag("display"),
        tonalElevation = 2.dp,
        shape = MaterialTheme.shapes.medium
    ) { ... }
}

データ層

package com.example.sampleProject.data.repository

/**
 * データ層: 実際の計算アルゴリズムをカプセル化し、他層からはリポジトリ経由でのみ利用できるようにする。
 */
class CalculatorRepositoryImpl : CalculatorRepository {
    override fun calculate(
        firstInput: String,
        secondInput: String,
        operation: CalculatorOperation

        return CalculationResult.Success(value)
    }
}

リファクタ後は、アプリを「責務ごとに独立した層」に分離しました。
UI が直接ビジネスロジックを触らず、ドメイン(ユースケースを含む)・データ・プレゼンテーションの3層が明確に責務分担する構造に変えました。ドメイン層では CalculatorRepository インターフェースを基点にユースケース PerformCalculationUseCase を介して計算の呼び出しを行い、実際のアルゴリズムはデータ層の実装CalculatorRepositoryImpl に切り離しています。

ViewModel が UI の窓口に

プレゼンテーション層では ViewModel が状態とイベントを一手に引き受け、Compose 画面は collectAsStateWithLifecycle でストリームを監視するだけの薄いレイヤーになりました。これにより、画面回転などのライフサイクル変化にも強く、UI テストとロジックテストを切り離しやすくなっています。

DI 用の AppModule でユースケース生成を一元化したので、将来的に別実装のリポジトリを差し替える際もViewModel側のコードを触らずに済みます。

リファクタ後のポイント

  1. UIとロジックの分離
    • UI(Compose)はViewModelの状態を描画するだけになり、計算や履歴の管理はすべてViewModel側に集約しています。
  2. テスト容易性の向上
    • PerformCalculationUseCaseCalculatorRepositoryImplは依存がないため、JUnitで単体テスト可能になりました。
  3. 責務が明確になり、保守しやすい
    • 将来的に「履歴をDB保存したい」などの拡張も、Data層のRepository実装を差し替えるだけで実現可能です。

最終的にMαinActivityは以下だけになりました。

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

実装して感じたメリット・デメリット

メリット

  • テストしやすい(UseCase単位で検証できる)
  • 責務が明確になって可読性が上がる
  • Android依存コードが限定される

デメリット

  • 初期構築コストが高い
  • 小規模アプリにはやや重め
  • チーム全員の理解が必要

まとめ

Clean Architectureは初期コストこそ高そうですが、後からきちんと効いてくる設計だと感じました。

リファクタ前はMainActivityにロジックが集中していましたが、Clean Architectureを導入することで、

  • UIとドメインロジックの分離
  • テストしやすい構成
  • 将来の拡張性

を同時に実現できたと思います。


株式会社ウイングドアは福岡のシステム開発会社です。
現在、私達と一緒に"楽しく仕事が出来る仲間"として、新卒・中途採用を絶賛募集しています!
ウイングドアの仲間達となら楽しく仕事できるかも?と興味をもった方、
お気軽にお問い合わせ下さい!

アーカイブ