안드로이드 위클리 공부 노트

#748

SKIE에서 Swift export로 옮기며 알게 된 것 — iOS에 내보내는 코드를 줄여라

원문 Moving from SKIE to Swift export in a Kotlin Multiplatform project, John O'Reilly

한 줄 요약

Android와 iOS가 Kotlin 코드를 함께 쓰는 샘플 앱 PeopleInSpace에서, iOS 앱이 Kotlin 코드를 불러 쓰는 방식을 SKIE라는 외부 도구에서 Kotlin이 직접 제공하는 Swift export 기능으로 바꾼 기록이다. 바꾸고 나니 iOS 쪽 코드는 조금만 고치면 됐다. 시간이 많이 든 일은 따로 있었다. Kotlin 쪽에서 iOS로 넘겨주는 코드의 양을 꼭 필요한 만큼만 남기도록 줄이는 일이었다.

이 글의 코드 블록은 저자의 샘플 앱 PeopleInSpace(Apache-2.0 라이선스 — 출처와 라이선스를 밝히면 가져다 쓸 수 있는 공개 라이선스)에서 인용했고, 블록마다 출처를 적었다. 그림은 원문 그림을 바탕으로 한국어로 다시 그렸다.

먼저 알아 둘 말

이 글에는 iOS 쪽 용어가 많이 나온다. 본문을 읽기 전에 한 번 훑어 두면 편하다.

서론 — 이 글은 무엇을 하려는가

저자는 글 첫머리에서 상황을 이렇게 정리한다.

서론에 나온 "Objective-C 프레임워크"와 "SKIE"가 왜 등장했는지 알아야 이 글의 의미가 보인다. 그래서 그 배경을 조금 더 풀어 둔다.

배경 ① — 기본 방식은 Objective-C를 거쳐서 부른다

배경 ② — 포장 과정에서 Kotlin의 좋은 기능이 사라진다

Objective-C는 Kotlin보다 표현할 수 있는 것이 적다. 포장을 거치면 이런 일이 생긴다.

배경 ③ — SKIE는 이 문제를 메워 주던 외부 도구다

Objective-C 포장만 썼을 때 생기는 문제 SKIE가 해 주는 일
sealed class를 Swift에서 하나씩 형변환해 봐야 하고, 빠뜨린 경우를 컴파일러가 잡아 주지 않는다 onEnum(of:) 함수가 sealed class를 Swift enum 으로 바꿔 준다. 그래서 switch 로 모든 경우를 빠짐없이 처리했는지 컴파일러가 검사해 준다
Flow의 값 타입이 사라져서 Swift에서 값을 꺼내 쓰기 어렵다 Flow를 Swift의 비동기 시퀀스(AsyncSequence — Swift에서 for await 로 값을 하나씩 받아 쓰는 통로)로 바꿔 준다. 값 타입도 그대로 유지된다
SwiftUI 화면에서 StateFlow 값이 바뀔 때마다 다시 그리려면 연결 코드를 직접 짜야 한다 Observing 이라는 SwiftUI 뷰(화면 조각)를 제공한다. StateFlow를 넘기면 값이 바뀔 때마다 화면이 알아서 다시 그려진다
Kotlin enum이 Swift enum이 아니다 진짜 Swift enum으로 바꿔 준다

1. Gradle 설정 바꾸기

① 예전 방식에 쓰던 설정을 지운다

common 모듈의 빌드 파일(common/build.gradle.kts)에서 세 가지를 지웠다.

② 내보내기만 담당하는 새 모듈 ios-export 를 만든다

kotlin {
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonMain.dependencies {
            api(projects.common)
        }
    }

    export {
        swift {
            moduleName = "Shared"
            xcodeIntegration {
                configure(projects.common) {
                    moduleName = "Common"
                    rootPackage = "dev.johnoreilly.common"
                }
                configure(libs.koin.core, SwiftExportVisibility.HIDDEN)
                configure(libs.kotlinx.serialization.core, SwiftExportVisibility.HIDDEN)
                configure(libs.androidx.lifecycle.viewmodel, SwiftExportVisibility.HIDDEN)            }
        }
    }
}

출처: PeopleInSpace ios-export/build.gradle.kts (PR #524, Apache-2.0)

한 줄씩 보면 이렇다.

③ Xcode 프로젝트 설정을 바꾼다

Xcode는 iOS 앱을 만드는 Apple 개발 도구다(Android Studio에 해당). 여기서도 세 가지를 바꿨다.

2. Swift로 내보내는 양 줄이기

저자는 이 작업에 가장 많은 시간이 들었다고 말한다.

처음 결과: 너무 많이, 그리고 깨진 채로 나왔다

내보내는 양을 정하는 규칙

라이브러리가 Swift로 얼마나 넘어가는지는, 내보내는 모듈에 그 라이브러리를 어떻게 의존시켰는지에 따라 정해진다.

의존 방식 Swift로 넘어가는 양 설명
내보내는 모듈이 api(...) 로 의존한 라이브러리 전부 그 라이브러리의 public 코드가 하나도 빠짐없이 Swift로 만들어진다. "이 모듈을 쓰는 쪽에도 그대로 보여 줘라"는 뜻이기 때문이다
그 밖의 라이브러리 (한 다리 건너 쓰이는 것) 쓰이는 부분만 내보내는 코드에서 실제로 닿는 타입과 함수만 만들어진다. 이를 "transitive(이어서 닿는 것만) 내보내기"라고 부른다
HIDDEN 으로 표시한 라이브러리 빈 껍데기만 우리 코드가 언급하는 타입의 이름만 남고 속은 비어 있다. 예를 들어 Koin은 약 2,000줄에서 22줄이 됐다. Koin이 쓰던 Kotlin 기본 라이브러리 코드도 약 1,700줄에서 200줄 아래로 함께 줄었다

그림으로 보면

모듈마다 Swift로 얼마나 넘어가는지 보여 주는 그림. ios-export 가 common 을 api 로 의존하고, common 은 전부 내보내진다. 코루틴과 Kotlin 표준 라이브러리는 쓰이는 부분만, Koin·kotlinx.serialization·ViewModel 은 빈 껍데기만, implementation 으로 의존하는 db 와 SQLDelight 는 내보내지지 않는다.
최종 구조에서 모듈마다 Swift로 넘어가는 양. 그림을 누르면 크게 볼 수 있다. 원문 그림을 바탕으로 한국어로 다시 그렸다.

빈 우산 모듈 ios-export 가 필요한 이유

common 자체도 줄였다 — explicitApi() 켜기

kotlin {
    explicitApi()
    ...
}

출처: PeopleInSpace common/build.gradle.kts (PR #524, Apache-2.0)

DB 코드를 별도 모듈로 뺐다

줄인 것의 부수 효과

3. Swift 코드 쪽 변화

불러오는 이름과 부르는 이름

만들어진 Swift 코드는 어떻게 생겼나

저자는 ViewModel 하나를 예로 보여 준다. 먼저 Kotlin 쪽 원본이다.

public class PersonListViewModel internal constructor(
    private val peopleInSpaceRepository: PeopleInSpaceRepositoryInterface
) : ViewModel() {

    public val uiState: StateFlow<PersonListUiState> = peopleInSpaceRepository.personListUiState()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonListUiState.Loading)

    public fun refresh() {
        viewModelScope.launch {
            peopleInSpaceRepository.fetchAndStorePeople()
        }
    }
}

출처: PeopleInSpace PersonListViewModel.kt (joreilly/PeopleInSpace, Apache-2.0)

이 클래스로 Swift export가 만든 Swift 코드는 다음과 같다(원문에서도 uiState 의 실제 몸통과 internal 생성자는 길어서 생략했다).

extension ExportedKotlinPackages.dev.johnoreilly.common.viewmodel {
    public final class PersonListViewModel: ExportedKotlinPackages.androidx.lifecycle.ViewModel {
        public var uiState: any KotlinCoroutineSupport.KotlinTypedStateFlow<ExportedKotlinPackages.dev.johnoreilly.common.viewmodel.PersonListUiState> {
            get {
                ...
            }
        }
        public func refresh() -> Swift.Void {
            return { dev_johnoreilly_common_viewmodel_PersonListViewModel_refresh(self.__externalRCRef()); return () }()
        }
    }
}

출처: 원문 글에 실린 생성 코드 Common.swift (원문)

StateFlow를 화면에 잇는 Observing 을 직접 만들었다

struct Observing<Value, Content: View>: View {
    private let flow: any KotlinTypedStateFlow<Value>
    private let content: (Value) -> Content

    @State private var value: Value

    init(
        _ flow: any KotlinTypedStateFlow<Value>,
        @ViewBuilder content: @escaping (Value) -> Content
    ) {
        self.flow = flow
        self.content = content
        self._value = State(initialValue: flow.value)
    }

    var body: some View {
        content(value)
            .task {
                do {
                    for try await next in flow.asAsyncSequence() {
                        value = next
                    }
                } catch {}
            }
    }
}

출처: PeopleInSpace Observing.swift (PR #524, Apache-2.0)

sealed class 처리

Observing(viewModel.uiState) { uiState in
    switch uiState.sealedType() {
    case .success(let success):
        // use success.value.result
    case .error(let failure):
        // use failure.value.message
    case .loading:
        ...
    }
}

출처: PeopleInSpace ContentView.swift (PR #524, Apache-2.0)

Kotlin 인터페이스를 Swift에서 구현하기

class iOSNativeViewFactory : KotlinBase, ui.NativeViewFactory {
    static var shared = iOSNativeViewFactory()

    func createISSMapView(viewModel: viewmodel.ISSPositionViewModel) -> UIViewController {
        let mapView = NativeISSMapView(viewModel: viewModel)
        return UIHostingController(rootView: mapView)
    }
}

출처: PeopleInSpace NativeViewFactory.swift (PR #524, Apache-2.0)

겪은 문제 — 숨긴 타입을 돌려주는 함수는 실행하면 죽는다

4. 저자의 정리

코드로 보기 — Aide 구조로 옮긴 스케치

원문 코드가 아니다. 이 글의 "internal 로 숨기고 밖에는 인터페이스만 보여 준다"를 Android 다중 모듈 앱(Hilt 사용)에 옮기면 어떤 모양일지 직접 쓴 스케치다.

// core:data 모듈

// ① 밖(feature 모듈)에 보여 줄 것은 인터페이스뿐이다. 표시가 없으니 public.
interface TaskRepository {
    fun observeTasks(): Flow<List<Task>>
    suspend fun complete(id: Long)
}

// ② 실제로 일하는 구현 클래스는 internal — core:data 모듈 밖에서는 이름조차 안 보인다.
internal class DefaultTaskRepository @Inject constructor(
    private val dao: TaskDao,
) : TaskRepository { /* ... */ }

// ③ "TaskRepository 를 달라고 하면 DefaultTaskRepository 를 줘라"는 Hilt 설정도 internal.
@Module
@InstallIn(SingletonComponent::class)
internal abstract class DataModule {
    @Binds
    abstract fun bindTaskRepository(impl: DefaultTaskRepository): TaskRepository
}

배운 점

Aide에 적용하면

Aide는 iOS 앱이 없는 Android 전용 앱이다. 그래서 Swift export와 SKIE 자체는 쓸 일이 없다. 하지만 이 글의 핵심인 "모듈 밖으로 보이는 것을 꼭 필요한 만큼으로 줄인다" 는 Aide의 다중 모듈 구조(app · feature:* · core:*)에 그대로 들어맞는다.

  1. Repository 구현 클래스와 Hilt 모듈을 internal 로 만든다 — 가져온다.
    • 대상: Task 5의 DefaultEventRepository(폰 캘린더 일정 읽기), Task 7의 DefaultTaskRepository(할 일 저장).
    • 처음 만들 때부터 위 스케치처럼 internal 로 둔다.
    • Aide는 "Repository 함수는 화면 전용이 아니라 동작 단위(만들기·지우기·완료·완료 취소)로 두고, 1-b에서 AI가 그 함수를 그대로 부른다"고 정했다. 밖에 보이는 것이 인터페이스뿐이어야 AI가 부를 수 있는 함수 목록도 인터페이스에 적힌 것만으로 깔끔하게 유지된다.
  2. Konsist 규칙으로 지킨다 — 바꿔서 가져온다.
    • 원문은 Kotlin 컴파일러의 explicitApi() 로 강제했다.
    • Aide에는 이미 Konsist(코드 구조 규칙을 테스트로 검사하는 라이브러리) 테스트가 있고, CI(코드를 올리면 자동으로 빌드·테스트하는 장치)에서 실패하면 막힌다.
    • 지금 있는 ModuleDependencyTest 옆에 "core.data 패키지의 Default...Repository 클래스는 internal 이어야 한다" 같은 규칙을 하나 더하는 편이 기존 방식과 맞는다.
  3. explicitApi() 를 모든 모듈에 켜기 — 지금은 안 한다.
    • 컨벤션 플러그인(모든 모듈에 같은 빌드 설정을 한 번에 적용하는 Aide의 build-logic)에서 켜면, 공개하는 모든 선언에 public 을 직접 써야 한다.
    • 1-a를 손으로 짜고 있는 지금은 그만큼 속도가 느려진다.
    • Konsist 규칙만으로 부족하다고 느껴질 때, core:* 라이브러리 모듈에만 켜는 것을 다시 검토한다.
  4. DB 코드 분리 — 이미 되어 있다. 연결 방식만 확인한다.
    • 원문은 SQLDelight 자동 생성 코드를 db 모듈로 빼고 implementation 으로 연결해 숨겼다(그림의 오른쪽 점선 상자).
    • Aide는 처음부터 Room 데이터베이스를 core:database 에 두고, 화면이 쓰는 core:data 와 나눠 두었다.
    • 다만 core:data 가 core:database 를 api(...) 로 의존하면 화면 모듈에도 DB 코드가 보이게 된다. implementation(...) 으로 걸었는지 확인할 필요가 있다.

출처