iOS App with Kotlin Native
이재민 팀장
2020-12-14
Overview
Kotlin Native
예제 Hello (Platform)!
예제 Networking and Data Storage
Conclusion
참고
Overview
Kotlin의 경우 현재 Android 개발에 많이 쓰이고 있습니다. Kotlin 언어 가이드에 따르면 현재
아래와 같은 플랫폼을 지원한다고 안내하고 있습니다.
- iOS (arm32, arm64, simulator x86_64)
- macOS (x86_64)
- watchOS (arm32, arm64, x86)
- tvOS (arm64, x86_64)
- Android (arm32, arm64, x86, x86_64)
- Windows (mingw x86_64, x86)
- Linux (x86_64, arm32, arm64, MIPS, MIPS little endian)
- WebAssembly (wasm32)
지원 목록 중에 눈에 띄는 iOS에 대하여 Kotlin으로 어느 정도까지 iOS App 개발이 가능하며, 어떤 방식으로 개발이 가능 한지 알아보기 위해, 아래 공식 문서에서 제공하는 샘플을 먼저 살펴 보았습니다.
Kotlin Native
멀티 플랫폼은 아직은 Alpha 버전이어서 자주 바뀔 수 있음을 경고하고 있습니다.
Kotlin 공통 코드를 사용하여 같은 라이브러리 지원을 받고, 플랫폼을 지원하는 컴파일러 위에서 코드를 작성하는 것임을 알 수 있습니다.
Android와 iOS 구현시 common에 expected class를 정의하고, 각 플랫폼별 actual class 를 통하여 실제 구현을 하도록 합니다.
Kotlin으로 전체 코드를 작성하는 것으로 예상 하였으나, 공식문서를 대략적으로 파악해보니 공통의
코드를 정의하고 상세 코드의 경우 개별적 작성을 하는 방식임을 알 수 있습니다.
예제 Hello (Platform)!
대략적인 흐름은 파악 하였으니, 이제 예제를 기반으로 실제 구현된 모습을 살펴 보겠습니다.
일반적으로 프로그래밍 언어를 배울 때 가장 처음으로 해보는 것은 역시나 hello world이기 때문에
가이드에서 제공하는 hello world 프로젝트를 우선 분석해 보겠습니다.
Android Studio 최신 업데이트 이후, 프로젝트 생성 시 아래 쪽에 멀티플랫폼을 지원하는 Template이 추가되었음을 확인할 수 있습니다.
프로젝트 생성 시 AndroidApp, commonMain, iOSMain의 3개로 구분된 프로젝트가 생성됩니다.
더불어 hello를 출력하기 위한 기본 코드 역시 생성되어 있습니다.
commonMain의 Platform
expect class Platform() {
val platform: String
}
androidMain의 Platform
actual class Platform actual constructor() {
actual val platform: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
iosMain의 Platform
actual class Platform actual constructor() {
actual val platform: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
commonMain의 expect class를 각 플랫폼별 Main에서 actual class 형태로 구현 되어 있습니다.
androidApp의 MainActivity
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.demo.shared.Greeting
import android.widget.TextView
fun greet(): String {
return Greeting().greeting()
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val tv: TextView = findViewById(R.id.text_view)
tv.text = greet()
}
}
iosApp의 ContentView
import SwiftUI
import shared
func greet() -> String {
return Greeting().greeting()
}
struct ContentView: View {
var body:some View {
Text(greet())
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
각 플랫폼 별로 구현된 main을 모듈로 사용하는 app을 구현합니다. Android는 Kotlin을 사용하여 ios는 xcode로 swift를 사용하여 UI 표현 합니다.
이후 각 플랫폼 별 시뮬레이터 실행했을 때, 정상적으로 출력 되는 것을 확인할 수 있습니다.
그동안 테스트 할 때 보았던 hello world가 아닌 hello + “플랫폼 정보” 이긴 하지만, Kotlin을 이용하여 ios 출력해볼 수 있다는 것이 굉장히 흥미로운 부분입니다.
예제 Networking and Data Storage
단순 텍스트 출력인 hello 예제 외에 좀 더 본격적인 예제를 살펴 보았습니다. 이제 네트워크를 통하여 api 호출 후 list를 통하여 출력하는 예제를 보여드리겠습니다.
commonMain의 DatabaseDriverFactory
import com.squareup.sqldelight.db.SqlDriver
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
androidMain의 DatabaseDriverFactory
import android.content.Context
import com.jetbrains.handson.kmm.shared.cache.AppDatabase
import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, context, "test.db")
}
}
iosMain의 DatabaseDriverFactory
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "test.db")
}
}
DatabaseDriverFactory class의 경우, 각 플랫폼별로 구현합니다.
RocketLaunch Data class
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RocketLaunch(
@SerialName("flight_number")
val flightNumber: Int,
@SerialName("mission_name")
val missionName: String,
@SerialName("launch_year")
val launchYear: Int,
@SerialName("launch_date_utc")
val launchDateUTC: String,
@SerialName("rocket")
val rocket: Rocket,
@SerialName("details")
val details: String?,
@SerialName("launch_success")
val launchSuccess: Boolean?,
@SerialName("links")
val links: Links
)
@Serializable
data class Rocket(
@SerialName("rocket_id")
val id: String,
@SerialName("rocket_name")
val name: String,
@SerialName("rocket_type")
val type: String
)
@Serializable
data class Links(
@SerialName("mission_patch")
val missionPatchUrl: String?,
@SerialName("article_link")
val articleUrl: String?
)
data class를 비롯한 domain 관련 된 부분은 Kotlin으로 작성된 공용 코드를 사용하는 구조입니다.
여기서 눈 여겨 볼 부분은, SqlDriver 라이브러리의 경우 Android는 AndroidSqliteDriver iOS는 NativeSqliteDriver를 사용하고 있다는 것입니다.
androidApp의 MainActivity
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.FrameLayout
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.jetbrains.handson.kmm.shared.SpaceXSDK
import com.jetbrains.handson.kmm.shared.cache.DatabaseDriverFactory
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import androidx.core.view.isVisible
import kotlinx.coroutines.cancel
class MainActivity : AppCompatActivity() {
private val mainScope = MainScope()
private lateinit var launchesRecyclerView: RecyclerView
private lateinit var progressBarView: FrameLayout
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private val sdk = SpaceXSDK(DatabaseDriverFactory(this))
private val launchesRvAdapter = LaunchesRvAdapter(listOf())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
title = "SpaceX Launches"
setContentView(R.layout.activity_main)
launchesRecyclerView = findViewById(R.id.launchesListRv)
progressBarView = findViewById(R.id.progressBar)
swipeRefreshLayout = findViewById(R.id.swipeContainer)
launchesRecyclerView.adapter = launchesRvAdapter
launchesRecyclerView.layoutManager = LinearLayoutManager(this)
swipeRefreshLayout.setOnRefreshListener {
swipeRefreshLayout.isRefreshing = false
displayLaunches(true)
}
displayLaunches(false)
}
override fun onDestroy() {
super.onDestroy()
mainScope.cancel()
}
private fun displayLaunches(needReload: Boolean) {
progressBarView.isVisible = true
mainScope.launch {
kotlin.runCatching {
sdk.getLaunches(needReload)
}.onSuccess {
launchesRvAdapter.launches = it
launchesRvAdapter.notifyDataSetChanged()
}.onFailure {
Toast.makeText(this@MainActivity, it.localizedMessage, Toast.LENGTH_SHORT).show()
}
progressBarView.isVisible = false
}
}
}
iosApp의 ContentView
import SwiftUI
import shared
struct ContentView: View {
@ObservedObject private(set) var viewModel: ViewModel
var body: some View {
NavigationView {
listView()
.navigationBarTitle("SpaceX Launches")
.navigationBarItems(trailing:
Button("Reload") {
self.viewModel.loadLaunches(forceReload: true)
})
}
}
private func listView() -> AnyView {
switch viewModel.launches {
case .loading:
return AnyView(Text("Loading...").multilineTextAlignment(.center))
case .result(let launches):
return AnyView(List(launches) { launch in
RocketLaunchRow(rocketLaunch: launch)
})
case .error(let description):
return AnyView(Text(description).multilineTextAlignment(.center))
}
}
}
extension ContentView {
enum LoadableLaunches {
case loading
case result([RocketLaunch])
case error(String)
}
class ViewModel: ObservableObject {
let sdk: SpaceXSDK
@Published var launches = LoadableLaunches.loading
init(sdk: SpaceXSDK) {
self.sdk = sdk
self.loadLaunches(forceReload: false)
}
func loadLaunches(forceReload: Bool) {
self.launches = .loading
sdk.getLaunches(forceReload: forceReload, completionHandler: { launches, error in
if let launches = launches {
self.launches = .result(launches)
} else {
self.launches = .error(error?.localizedDescription ?? "error")
}
})
}
}
}
extension RocketLaunch: Identifiable { }
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
/*@START_MENU_TOKEN@*/Text("Hello, World!")/*@END_MENU_TOKEN@*/
}
}
hello 예제와 동일하게 각 플랫폼 별 list 라이브러리를 이용하여 ui를 표현해 줍니다.
실행 결과 각 플랫폼 별로 리스트가 출력됩니다.
Conclusion
Reactive Native 와 flutter를 비롯한 멀티플랫폼의 경우 native에 비하여 느리거나 기존 native 개발자들이 새롭게 언어를 배워야 하는 문제가 있습니다. Kotlin의 경우 기존 Android 개발자가 추가 학습 없이 개발이 가능하기 때문에 쉽게 iOS App 개발이 가능하지 않을까, 라고 생각하였습니다.
의문에 대한 답을 찾기 위해 가볍게 살펴 본 Kotlin Native는, 기존 생각과 달리 모든 부분을 Kotlin 으로 작성 하는 것은 아니었습니다. Kotlin 개발자가 iOS 개발을 한다기보다는 공통 domain 영역을 Kotlin으로 작성하고, 이 부분을 라이브러리처럼 사용하면서 UI를 해당 플랫폼의 언어로 개발하는 형태입니다.
대다수 Android와 iOS App을 병행 개발 한다고 할 때 domain 부분에 대한 처리를 양쪽이 동일한 형태로 만들 수 있는 장점이 있습니다. iOS쪽은 UI부분에 좀 더 집중할 수 있을 것이고요. 플랫폼 별 UI 구현 역시 native 속도 또한 잃지 않을 수 있습니다. 하지만 이것은 happy case에 대한 가정입니다. 현재 간단한 예제를 따라 만들어 보면 Kotlin Native는 아직 Alpha이기 때문이라는 점, 더불어 문서에 최신 tool의 내용이 미반영 되어 있다는 것, bug인지 알수 없는 tool상의 오류, ios의 경우 공통 영역의 디버깅이 어렵다는 점 등과 참고 자료 역시 충분하지 않다는 문제점이 보입니다.
향후 정식 버전까지 문제점에 대하여 지속적인 수정과 보안이 이루어진다면 분명 Kotlin Native 역시 멀티 플랫폼의 한가지 대안이 될수 있다고 생각합니다. Swift 또한 단순히 iOS 개발만을 위한 언어가 아니기 때문에 ‘Swift로 Android 지원이 가능해진다면 어떨까?’하는 생각을 하며 Kotlin Native에 대한 글을 마무리 합니다.
참고
https://kotlinlang.org/docs
https://play.kotlinlang.org/hands-on/Networking and Data Storage with Kotlin Multiplatfrom Mobile/01_Introduction
https://www.raywenderlich.com/7357-ios-app-with-kotlin-native-getting-started