前回の記事はこちら:

iOS(SwiftUI + Observation/Combine)
MVVM + SwiftUI + Observation(@Observable/@State/@Environment)
概要:
- View は「状態の関数」。状態は ViewModel が持ち、View は“読むだけ”。
- iOS 17+ は Observation(
@Observable
)が標準で軽量。 - iOS 16 以前は
ObservableObject
+ Combine(@Published
)が実務定番。
例:
// ViewModel(iOS 17+ の軽量観測モデル)
import Observation
@Observable
final class CounterViewModel {
var count = 0
func increment() { count += 1 }
}
// View(状態を読むだけ)
import SwiftUI
struct CounterView: View {
@State private var viewModel = CounterViewModel() // 参照を @State で保持
var body: some View {
VStack(spacing: 16) {
Text("Count: \(viewModel.count)")
Button("+1") { viewModel.increment() }
}
.padding()
}
}
依存の注入(Environment ベースの超シンプル DI)
// 依存のプロトコルと実装
protocol UserRepository {
func fetchName(id: String) async throws -> String
}
struct RealUserRepository: UserRepository {
func fetchName(id: String) async throws -> String { "Alice #\(id)" }
}
// EnvironmentKey で差し替え可能に
private struct UserRepositoryKey: EnvironmentKey {
static let defaultValue: UserRepository = RealUserRepository()
}
extension EnvironmentValues {
var userRepository: UserRepository {
get { self[UserRepositoryKey.self] }
set { self[UserRepositoryKey.self] = newValue }
}
}
// View で利用
struct ProfileView: View {
@Environment(\.userRepository) private var repository
@State private var name = "Loading..."
var body: some View {
Text(name)
.task { name = (try? await repository.fetchName(id: "1")) ?? "Guest" }
}
}
Unidirectional Data Flow(The Composable Architecture など)
ポイント:
- 状態は単一のソースに集約、イベント(Action)で変更、Reducerでしか状態を書き換えない。
例:
// TCA(概要のみ・最小例)
import ComposableArchitecture
@Reducer
struct CounterFeature {
@ObservableState
struct State {
var count = 0
}
enum Action {
case increment
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .increment:
state.count += 1
return .none
}
}
}
}
struct CounterTCAView: View {
let store: StoreOf<CounterFeature>
var body: some View {
WithViewStore(store, observe: { $0 }) { vs in
VStack {
Text("Count: \(vs.count)")
Button("+1") { vs.send(.increment) }
}
}
}
}
Clean Architecture(UseCase / Repository 分離)
ポイント:
- UI とドメインを疎結合に。ViewModel は UseCase を呼ぶだけ。
例:
// UseCase
struct LoadUserName {
let repo: UserRepository
func callAsFunction(_ id: String) async throws -> String {
try await repo.fetchName(id: id)
}
}
// ViewModel(UI 層)
@Observable
final class ProfileViewModel {
private let loadUserName: LoadUserName
init(loadUserName: LoadUserName) {
self.loadUserName = loadUserName
}
var name = "Loading..."
func onAppear() async {
name = (try? await loadUserName("1")) ?? "Guest"
}
}
DI の選択肢:
- Protocol + Factory(標準構文だけでシンプル)
- サービスロケータ(例:Resolver)
- SwiftPM のターゲット分割で“モジュール DI”風に管理
NavigationStack とデータフロー
ポイント:
- 状態を親に集約し、子画面へ値をバインドや Environmentで渡す。
例:
struct AppView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(1..<4) { n in
NavigationLink("Detail \(n)", value: n)
}
.navigationDestination(for: Int.self) { n in
Text("Detail \(n)")
}
.navigationTitle("Home")
}
}
}
Android(Kotlin + Jetpack Compose)
MVVM + Compose + Flow/State(StateFlow / mutableStateOf)
ポイント:
- ViewModel が
StateFlow
(またはmutableStateOf
)を公開 - Compose は
collectAsState()
で購読。
例:
// ViewModel
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.update { it + 1 }
}
}
// UI(Compose)
@Composable
fun CounterScreen(vm: CounterViewModel = viewModel()) {
val count by vm.count.collectAsState() // Flow -> State にブリッジ
Column(Modifier.padding(16.dp)) {
Text("Count: $count")
Button(onClick = vm::increment) {
Text("+1")
}
}
}
MVI/Redux ライク(UDF)
ポイント:
- Action → Reducer → 新しい State。副作用は別(Middleware/UseCase)に。
// 状態とアクション
data class CounterState(val count: Int = 0)
sealed interface CounterAction {
data object Inc : CounterAction
}
// “Reducer” 的な更新
class CounterFeature {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state.asStateFlow()
fun send(action: CounterAction) = when (action) {
CounterAction.Inc -> _state.update {
it.copy(count = it.count + 1)
}
}
}
@Composable
fun CounterMviScreen(feature: CounterFeature = remember { CounterFeature() }) {
val state by feature.state.collectAsState()
Column(Modifier.padding(16.dp)) {
Text("Count: ${state.count}")
Button(onClick = { feature.send(CounterAction.Inc) }) {
Text("+1")
}
}
}
Clean Architecture(UseCase / Repository 分離)
// ドメイン
interface UserRepository {
suspend fun fetchName(id: String): String
}
class LoadUserName(private val repo: UserRepository) {
suspend operator fun invoke(id: String) = repo.fetchName(id)
}
// ViewModel
class ProfileViewModel(private val loadUserName: LoadUserName) : ViewModel() {
var name by mutableStateOf("Loading...")
private set
fun onAppear() = viewModelScope.launch {
name = runCatching { loadUserName("1") }.getOrElse { "Guest" }
}
}
DI(Hilt / Koin)
ポイント:
- Hilt (公式推奨) は Dagger ベースでスコープ管理が堅牢。Koinは DSL ベースで手軽。
例:
// Hilt の最小例(依存提供)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides fun provideRepo(): UserRepository = RealUserRepository()
}
// Koin の最小例(DSL)
val appModule = module {
single<UserRepository> { RealUserRepository() }
factory { LoadUserName(get()) }
}
Navigation-Compose と BackStack
ポイント:
- NavHost でグラフ定義、
navController.navigate("detail/$id")
で遷移。
例:
@Composable
fun AppNav() {
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") {
HomeScreen(onOpen = { id -> navController.navigate("detail/$id") })
}
composable("detail/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id") ?: "?"
DetailScreen(id)
}
}
}
共通原則
- 単一ソースの状態:画面の状態は一箇所に集約(ViewModel/Feature/Store)。
- 副作用の分離:ネットワーク・DB・ログなどは UseCase/Repository/Effect に寄せる。
- テスト容易性:UI は純粋関数に近づけ、依存はインターフェース越しに差し替える。
- ナビゲーションは“データ駆動”:ルートやパス、ID を型で表す(
enum
/sealed
など)と迷子になりにくい。
スレッド境界:
- iOS:UI 更新は
@MainActor
、重い処理はTask { ... }
/Task.detached
でオフロード。 - Android:UI は
Dispatchers.Main
、重い処理はwithContext(Dispatchers.IO)
/viewModelScope.launch(Dispatchers.IO)
。
まとめ (1 ~ 3まで)
- View = State → UI(処理は ViewModel/UseCase)
- UDF(一方向データフロー):Action 以外で状態を書き換えない
- 不変を基本に(Swift:
struct
、Kotlin:data class + copy
) - 例外/エラー方針を決める(Swift:
throws
/Result
、Kotlin:try/catch
/Result
) - 非同期は構造化(Swift:
async/await
、Kotlin: Coroutines/Flow) - DIで差し替え可能に(テストしやすく、層が自立)
- ナビゲーションはデータ駆動・型安全志向
リソース:
- Navigation-Compose(Android 公式)。 Android Developers+1
- Apple Observation(
@Observable
概要)と移行ガイド。 Apple Developer+1 - SwiftUI NavigationStack / Navigation ガイド。 Apple Developer+2Apple Developer+2
- Combine 概要と WWDC セッション。 Apple Developer+2Apple Developer+2
- Point-Free The Composable Architecture(ドキュメント/チュートリアル/SPM ドキュメント)。 pointfreeco.github.io+2pointfreeco.github.io+2
- Android 公式 アーキテクチャガイド。 Android Developers
- Compose の State & UDF(
collectAsState
等)と アーキテクチャ。 Android Developers+1 - Kotlin Coroutines/Flow(公式)。 Kotlin+2Kotlin+2
- Hilt(Android 公式/Dagger 公式)。 Android Developers+2Android Developers+2
- Koin(公式サイト/GitHub)。 insert-koin.io+1