[Xcode/Swift] Swift ⇄ Kotlin 脳内スイッチ・チートシート③

前回の記事はこちら:

[Xcode/Swift] Swift ⇄ Kotlin 脳内スイッチ・チートシート②

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で差し替え可能に(テストしやすく、層が自立)
  • ナビゲーションはデータ駆動・型安全志向

リソース: