코틀린 멀티플랫폼을 통해 Android/iOS를 연구/개발하면서 슬슬 iOS Platform API를 통해 Native Feature를 다뤄봐야지라는 생각을 하였습니다. 사실 그전까지 안드로이드/데스크탑 위주의 코틀린 멀티플랫폼만을 만들기도 하였고 Swift는 다루지 못하기에 그동안 제한이 있었습니다.
그러다 지난 4월 Swift 공부를 하면서 간단하게나마 개발을 해보자는 생각을 하였고 무엇을 만들까 고민하다 토치(플래시)앱을 주제로 삼았습니다.
결과물 미리보기
Logic Share 범위 설정
우선 가장 먼저 고민한 부분은 토치 기능 뿐만이 아니라 UI까지 컴포즈 멀티플랫폼으로 구현할지, 아니면 UI를 제외한 토치 기능만 코틀린 멀티플랫폼으로 구현할지 고민하였습니다.
UI를 제외한 공통된 로직(토치 기능)을 코틀린으로 작성하여 각 네이티브에서 호출한다는 장점이 있고, UI를 포함하면 로직 하나로 크로스 플랫폼을 구현한다는 장점이 있었습니다. 물론 토치의 경우 Native에 맞게 각 코드를 구현해야 하기에 완벽한 단일 크로스 플랫폼은 아닐 듯싶습니다.
즉 UI까지 컴포즈를 통한 코틀린으로 개발한다면 RN, Flutter와 같은 유사 크로스플랫폼 구현하는 형태가 될 것이고, UI를 제외한 로직을 각 네이티브에서 사용하는 형태라면 공통 로직을 멀티플랫폼에서 사용하는 형태가 될 것입니다.
코틀린 멀티플랫폼 (UI 제외)
- Kotlin Multiplatform
- Torch
- Android
- Compose
- call torch
- Compose
- iOS
- SwiftUI
- call torch
- SwiftUI
컴포즈 멀티플랫폼 (UI 포함)
- Kotlin Multiplatform
- Torch
- Compose Multiplatform
- call torch
- Android
- Compose
- iOS
- Compose
무엇을 선택할까 고민하다 그냥 두 개 다 만들기로 생각했습니다. 우선 UI를 제외한 토치 기능만 코틀린 멀티플랫폼으로 만들어 Compose/Swift UI에서 각각 호출하는 방식으로 구현을 하고 이후 컴포즈 멀티플랫폼을 통해 하나로 합치도록 구상하였습니다.
구상도
우선 간단하게 모듈의 기능은 다음과 같이 구상하였습니다.
- turnOn()
- 토치를 활성화합니다.
- turnOff()
- 토치를 비활성화합니다.
- isAvailable
- 토치 사용 가능 여부를 반환합니다.
- isEnabledFlow
- 현재 토치 활성화 여부를 Flow로 반환합니다.
여기서 isEnabled로 토치 상태를 Flow가 아닌 단발성으로 가져오는 기능도 고려하였으나, 안드로이드에서는 토치의 플래시 상태를 가져오기 위해서는 Callback으로 가져와야 하기에 해당 기능은 제외하였습니다. 만약 구현한다면 Callback을 등록하면서 해당 값을 바꿔주도록 구현하거나 iOS에만 해당 function을 구현하면 됩니다.
expect
우선 토치 관련 함수/변수를 commonMain 모듈에 expect로 선언함으로써 android/iOS 각각 구현해야 하는 것을 알립니다. expect에 대해 모르시는 분들을 위해 간단한 부연설명을 드리자면, 일종의 interface와 유사한 기능으로 보시면 좋을 것 같습니다. 다른 점이 있다면 해당 expect(클래스, 오브젝트, 함수, 변수 등이 될 수 있음)를 각 플랫폼에서 반드시 구현(actual)해줘야 하며, 실행 시 해당 네이티브에 맞는 actual 코드가 실행됩니다.
expect class TorchController {
fun turnOn()
fun turnOff()
val isAvailable: Boolean
val isEnabledFlow: Flow<Boolean>
}
이제 공통으로 사용할 것으로 예상(expect)하였으니 각 플랫폼에 맞게 실제(actual)로 구현해 줍니다.
actual
본격적으로 토치 기능을 구현하게 됩니다. 만약 expect로 예상을 하였지만, 빌드하는 플랫폼에서 actual로 실제 구현을 하지 않았다면 no actual declaration in module
와 같은 빌드 오류가 발생하게 됩니다.
이 포스팅은 코틀린 멀티플랫폼이 핵심이기에 CameraManager로 안드로이드의 토치 제어하는 기본적인 설명은 따로 생략하도록 하겠습니다.
Android
혹시나 callbackFlow를 모르시는 분들이 계실 수 있기에 간단하게 설명드리자면, callback 형태의 이벤트를 마치 스트림처럼 처리할 수 있게 됩니다. 또한 해당 스트림이 끊기게 된다면(Flow 구독이 끊긴다면) awaitClose를 통해 간단하게 callback을 해제할 수 있습니다.
actual class TorchController(context: Context) {
private val cameraManager: CameraManager =
context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private val cameraId: String? = cameraManager.cameraIdList.find { cameraId ->
cameraManager.getCameraCharacteristics(cameraId)
.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
}
@Throws(Exception::class)
actual fun turnOn() {
cameraId ?: throw Exception("No device found")
cameraManager.setTorchMode(cameraId, true)
}
@Throws(Exception::class)
actual fun turnOff() {
cameraId ?: throw Exception("No device found")
cameraManager.setTorchMode(cameraId, false)
}
actual val isAvailable: Boolean
get() = cameraId?.let {
cameraManager.getCameraCharacteristics(it)
.get(CameraCharacteristics.FLASH_INFO_AVAILABLE)
} ?: false
actual val isEnabledFlow = callbackFlow {
val callback = object : CameraManager.TorchCallback() {
override fun onTorchModeChanged(cameraId: String, enabled: Boolean) {
trySend(enabled)
}
}
cameraManager.registerTorchCallback(callback, null)
awaitClose {
cameraManager.unregisterTorchCallback(callback)
}
}
}
iOS
iOS의 경우에는 조금 더 심플할 수 있습니다. 코틀린으로 iOS의 토치를 제어하기 위해서는 AVCaptureDevice를 가져와야 하기에 AVFoundation
을 통해 AVCaptureDevice를 가져옵니다.
val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
이후 각 함수와 변수를 실제로 구현해주어야 합니다. 이때 토치 모드를 제어하는 경우 lockForConfiguratio()
으로 잠금 한 이후 작업해야 하며, 사용이 완료되면 다시 unlockForConfiguration()
을 통해 해제해야 합니다.
매번 작성하면 번거롭기에 해당 작업을 확장 함수로 통일하였습니다.
private fun AVCaptureDevice.useConfiguration(action: () -> Unit) {
lockForConfiguration(null)
action()
unlockForConfiguration()
}
이후 만든 device와 useConfiguration을 통해 각 기능을 구현해 줍니다. 이때 device가 존재하지 않는 경우를 핸들링하기 위해 @Throws
어노테이션과 함께 device가 null인 경우 throw
를 하도록 추가하였습니다.
@Throws
어노테이션을 사용하면 발생하는 Kotlin Exception
을 Swift Exception
으로 자동 컨버팅 해줍니다.
actual class TorchController {
private val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
@Throws(Exception::class)
actual fun turnOn() {
device ?: throw Exception("No device found")
device.useConfiguration {
device.setTorchMode(AVCaptureTorchModeOn)
}
}
@Throws(Exception::class)
actual fun turnOff() {
device ?: throw Exception("No device found")
device.useConfiguration {
device.setTorchMode(AVCaptureTorchModeOff)
}
}
actual val isAvailable: Boolean
get() = device?.isTorchAvailable() == true
actual val isEnabledFlow: Flow<Boolean> = TODO("Not yet implemented")
private fun AVCaptureDevice.useConfiguration(action: () -> Unit) {
lockForConfiguration(null)
action()
unlockForConfiguration()
}
}
토치 활성화 여부 스트림
그러나 이렇게 쉬우면 안 되겠죠...? 사실 이 부분이 가장 문제였습니다. 안드로이드의 경우에는 registerTorchCallback
으로 처리하면 되지만 iOS에서는 다음과 같이 NSObject
로 observeValue
를 override 하여 observer
을 만들고 추가해주어야 합니다.
/// TorchUtil.swift
let device = AVCaptureDevice.default(for: .video)
let observer = TorchModeObserver()
// 코드 생략...
func initObservation() {
guard let device = device else { return }
device.addObserver(observer, forKeyPath: "torchMode", options: [.initial, .new], context: nil)
}
/// TorchModeObserver.swift
TorchModeObserver: NSObject {
@Published var torchMode = AVCaptureDevice.TorchMode.off
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "torchMode" {
torchMode = change?[.newKey] as? Int == AVCaptureDevice.TorchMode.on.rawValue ? AVCaptureDevice.TorchMode.on : AVCaptureDevice.TorchMode.off
}
}
}
여기서 문제는 코틀린에서 해당 NSObject
내의 observeValue
함수를 제공하지 않아 override 할 수 없기에 토치 상태를 구독할 수 없습니다. 이는 Objective-C에서 카테고리 함수로 선언되어 코틀린에서 재정의할 수 없는 확장 함수로 가져오기에 기본적으로 KVO(Key-Value Observing)를 사용할 수 없습니다.
해결 방법을 아무리 찾아도 관련 정보가 매우 드물고(정말 몇 건 정도) 이 마저도 해결법에 대한 내용이 없거나 적용하기에는 무리가 있었습니다. 그렇게 4개월 동안 틈틈이 시도의 시도의 시도를 반복하여 def
를 통해 interop
하는 방식으로 구현에 성공하였습니다. 코틀린 멀티플랫폼에서 KVO를 사용하는 방법으로 이렇게 자세하게 설명하는 건 최초이지 않을까 싶네요
아래의 코드를 src/nativeInterop/cinterop에 Observer.def로 저장한 후
language = Objective-C
---
#import <Foundation/Foundation.h>
@protocol Observer
@required
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey, id> *)change
context:(void *)context;
@end
Gradle에서 해당 파일을 참조할 수 있도록 경로를 지정하여 링크하고 Gradle Sync를 해주시면 됩니다.
val path = projectDir.resolve("src/nativeInterop/cinterop/Observer")
binaries.all {
linkerOpts("-F $path")
linkerOpts("-ObjC")
}
compilations.getByName("main") {
cinterops.create("Observer") {
compilerOpts("-F $path")
}
}
def에서 따로 package를 지정하지 않으셨다면 Observer
패키지로 지정되며 아래와 같이 Observer를 만들 때 NSObject에 ObserverProtocol
를 추가해 주시면 됩니다. 이렇게 되면 기존에 override 할 수 없던 observeValueForKeyPath
를 사용할 수 있게 되며, 이를 통해 변경되는 값을 받아올 수 있습니다.
class TorchModeObserver : NSObject(), ObserverProtocol {
override fun observeValueForKeyPath(
keyPath: String?,
ofObject: Any?,
change: Map<Any?, *>?,
context: COpaquePointer?
) {
TODO("Not yet implemented")
}
}
그리고 값을 전달하기 위해 고차로 상태를 전달하도록 onStatusChanged
파라미터를 추가하고, 내부에서는 change로 넘어온 값을 받아 넘겨줍니다. 이때 값이 Long? 인 이유는 혹시 모를 Null 방지와 값이 true, false로 넘어오는 것이 아닌 Long으로 넘어오기 때문입니다.
class TorchModeObserver(private val onStatusChanged: (Long) -> Unit) : NSObject(), ObserverProtocol {
override fun observeValueForKeyPath(
keyPath: String?,
ofObject: Any?,
change: Map<Any?, *>?,
context: COpaquePointer?
) {
(change?.get("new") as? Long)?.let(onStatusChanged)
}
}
마지막으로 안드로이드와 마찬가지로 callbackFlow를 통해 내부에서 해당 Observer을 device에 등록함으로써 상태를 실시간으로 방출할 수 있게 구현하였습니다. 또한 Flow의 구독이 끊기면 Observer를 해지해주기 위해 awaitClose에서 removeObserver도 함께 넣어주었습니다.
actual val isEnabledFlow: Flow<Boolean> = callbackFlow {
if (device == null) {
println("WARNING: No device found. It's not possible to observe torch mode changes.")
close()
return@callbackFlow
}
val observer = TorchModeObserver {
trySend(it == 1L)
}
device.addObserver(
observer,
forKeyPath = "torchMode",
options = NSKeyValueObservingOptionInitial or NSKeyValueObservingOptionNew,
context = null
)
awaitClose {
device.removeObserver(observer, forKeyPath = "torchMode")
}
}.distinctUntilChanged()
만약 위의 내용이 어려우시다면 아름답지는 못하지만 while을 통해 꼼수로 구현은 가능합니다. 사실 위의 방법을 찾지 못하였을 당시 아래의 코드로 구현하였었습니다.
actual val isEnabledFlow: Flow<Boolean> = flow {
while (currentCoroutineContext().isActive) {
emit(device.torchMode == AVCaptureTorchModeOn)
delay(100)
}
}.distinctUntilChanged()
아래 내용은 실제로 interop 하는 방법을 찾지 못했을 때 작성해 두었던 멘트입니다.
만약 실제 서비스라면 상태 부분은 각 Native에서 상태를 관찰하도록 구현하거나 버튼 클릭 및 라이프 사이클에 따라 수동으로 status를 get/set 하는 것이 좋은 해결책이 될 것 같지만... 이 프로젝트의 주 목표는 최대한 코틀린으로 네이티브를 접근하여 만들어보는 Research 이기에 아쉽지만 위의 방식을 채택하였습니다. 만약 해당 부분을 네이티브에서 처리하고 싶으시다면 위의 토치 활성화 여부 스트림 섹션의 코드를 활용하시면 됩니다.
최종 iOS의 TorchController 코틀린 코드는 다음과 같습니다.
actual class TorchController {
private val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
@Throws(Exception::class)
actual fun turnOn() {
device ?: throw Exception("No device found")
device.useConfiguration {
device.setTorchMode(AVCaptureTorchModeOn)
}
}
@Throws(Exception::class)
actual fun turnOff() {
device ?: throw Exception("No device found")
device.useConfiguration {
device.setTorchMode(AVCaptureTorchModeOff)
}
}
actual val isAvailable: Boolean
get() = device?.isTorchAvailable() == true
actual val isEnabledFlow: Flow<Boolean> = callbackFlow {
if (device == null) {
println("WARNING: No device found. It's not possible to observe torch mode changes.")
close()
return@callbackFlow
}
val observer = TorchModeObserver {
trySend(it == 1L)
}
device.addObserver(
observer,
forKeyPath = "torchMode",
options = NSKeyValueObservingOptionInitial or NSKeyValueObservingOptionNew,
context = null
)
awaitClose {
device.removeObserver(observer, forKeyPath = "torchMode")
}
}.distinctUntilChanged()
private fun AVCaptureDevice.useConfiguration(action: () -> Unit) {
lockForConfiguration(null)
action()
unlockForConfiguration()
}
}
class TorchModeObserver(private val onStatusChanged: (Long) -> Unit) : NSObject(),
ObserverProtocol {
override fun observeValueForKeyPath(
keyPath: String?,
ofObject: Any?,
change: Map<Any?, *>?,
context: COpaquePointer?
) {
(change?.get("new") as? Long)?.let(onStatusChanged)
}
}
UI 만들기 및 기능 연결
이제 네이티브 기능은 구현되었으니 UI를 만들고 연결해 보도록 하겠습니다.
안드로이드
안드로이드는 마찬가지로 큰 설명 없이 핵심 위주로만 설명하겠습니다.
expect로 예상하고 안드로이드에서 actual로 만든 TorchController를 생성합니다. 이때 lazy로 선언한 이유는 우리가 구현한 안드로이드 TorchController는 CameraManager를 사용하기에 생성자로 context를 받고 있는데, 당연하게도 액티비티 클래스가 생성되자마자 Context가 만들어지지 않은 시점에 정의하게 되면 context 오류가 발생하기에 안전하게 접근하기 위함입니다.
/// MainActivity.kt
class MainActivity : ComponentActivity() {
private val torchController by lazy {
TorchController(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val isTorchEnabled by torchController.isEnabledFlow.collectAsState(initial = false)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Torch Enabled: $isTorchEnabled")
Button(onClick = torchController::turnOn) {
Text(text = "Turn On")
}
Button(onClick = torchController::turnOff) {
Text(text = "Turn Off")
}
}
}
}
}
}
}
추가적으로 turnOn
, turnOff
함수 실행 시 디바이스에 카메라가 존재하지 않는 경우를 대비해 예외 처리를 해줍니다. 그리고 오류가 발생하는 경우 AlertDialog
를 노출하도록 구현합니다.
Box(...) {
...
var isVisibleTorchError by remember { mutableStateOf(false) }
Column(...) {...}
AnimatedVisibility(visible = isVisibleTorchError) {
AlertDialog(
onDismissRequest = { isVisibleTorchError = false },
confirmButton = {
Button(onClick = { isVisibleTorchError = false }) {
Text(text = "OK")
}
},
title = {
Text(text = "ERROR: TORCH NOT FOUND")
},
text = {
Text(text = "This device does not have a camera with a flash.")
}
)
}
}
그리고 각 함수에 try-catch
으로 감싸고 오류 발생 시 isVisibleTorchError
을 true
로 만들어줍니다.
Column(...) {
...
Button(onClick = {
try {
torchController.turnOn()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn On")
}
Button(onClick = {
try {
torchController.turnOff()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn Off")
}
}
최종적으로 안드로이드 코드는 다음과 같습니다.
class MainActivity : ComponentActivity() {
private val torchController by lazy {
TorchController(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val isTorchEnabled by torchController.isEnabledFlow.collectAsState(initial = false)
var isVisibleTorchError by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Torch Enabled: $isTorchEnabled")
Button(onClick = {
try {
torchController.turnOn()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn On")
}
Button(onClick = {
try {
torchController.turnOff()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn Off")
}
}
AnimatedVisibility(visible = isVisibleTorchError) {
AlertDialog(
onDismissRequest = { isVisibleTorchError = false },
confirmButton = {
Button(onClick = { isVisibleTorchError = false }) {
Text(text = "OK")
}
},
title = {
Text(text = "ERROR: TORCH NOT FOUND")
},
text = {
Text(text = "This device does not have a camera with a flash.")
}
)
}
}
}
}
}
}
iOS
이제 iOS를 만들 차례로 처음 안내를 했던 것처럼 SwiftUI로 먼저 구현하고 이후 Compose로 만들겠습니다. 우선 기본 UI를 만듭니다.
var body: some View {
VStack {
Text("Torch Enabled: ")
Button("Turn On", action: {
// TODO turn on
})
Button("Turn Off", action: {
// TODO turn off
})
}
}
그다음 torchController
를 만들고 오류가 발생한 경우 알리기 위한 isVisibleTorchError
상태도 함께 만듭니다. 이는 앞서 말했던 것 처럼 iOS 시뮬레이터와 같이 AVCaptureDevice가 존재하지 않는 상황에서 토치 제어 시 예외처리를 하지 않았다면 오류가 발생할 것이며 예외처리를 해도 따로 노출을 하지 않는다면 사용자는 알 방법이 없기에 Alert으로 안내를 해주기 위함입니다.
private let torchController = TorchController()
@State private var isVisibleTorchError = false
그리고 해당 torchController
의 turnOn
, turnOff
함수를 통해 토치를 제어합니다. 이때 @Throws
어노테이션을 붙여두었기에 do-try-catch
를 반드시 해주어야 합니다.
Button("Turn On", action: {
do {
try torchController.turnOn()
} catch {
isVisibleTorchError = true
print("turn-on ERROR: \(String(describing: error))")
}
})
Button("Turn Off", action: {
do {
try torchController.turnOff()
} catch {
isVisibleTorchError = true
print("turn-off ERROR: \(String(describing: error))")
}
})
오류가 발생하면 Alert을 노출해 주기 위해 isVisibleTorchError
을 바라보는 Alert
을 만들어줍니다.
VStack {
...
}.alert(isPresented: $isVisibleTorchError) {
Alert(
title: Text("ERROR: TORCH NOT FOUND"),
message: Text("This device does not have a camera with a flash."),
dismissButton: .default(Text("OK"))
)
}
이제 현재 토치 상태를 실시간으로 보여주기 위해 만든 isEnabledFlow
를 사용할 차례입니다. 다만 iOS에서 Flow를 바로 사용할 수는 없으며 Collector
를 통해 값을 받아올 수 있습니다. 이를 위해 Kotlinx_coroutines_coreFlowCollector
와 ObservableObject
를 상속받는 Collector를 만들어줍니다. 만약 공용 Collector로 만들고 싶다면 BooleanCollector 등으로 명명하셔도 됩니다. 저는 보통 타입별 공통 Collector를 만들어 재사용하고 있습니다.
class TorchEnabledCollector: Kotlinx_coroutines_coreFlowCollector, ObservableObject {
func emit(value: Any?) async throws {
// TODO
}
}
그다음 값을 보관하기 위해 isEnabled
를 만듭니다. 마찬가지로 공통 Collector를 원하신다면 value와 같은 이름으로 명명하셔도 됩니다. 이때 값이 변경되면 UI를 갱신을 할 수 있도록 @Published
로 선언합니다. emit
함수에서 넘어온 값을 캐스팅하여 isEnabled
에 저장합니다.
여기서 @MainActor
를 사용하였는데, 이는 MainThread
에서 UI를 갱신하도록 하기 위함입니다. 즉 백그라운드에서 지속적으로 갱신이 되어야 하는 경우에는 @MainActor
어트리뷰트를 제거해야 합니다.
class TorchEnabledCollector: Kotlinx_coroutines_coreFlowCollector, ObservableObject {
@Published var isEnabled = false
@MainActor func emit(value: Any?) async throws {
isEnabled = value as? Bool? == true
}
}
열심히 Collector를 만들었다면 torchEnabledCollector
변수로 만들고 init
에서 torchController
의 isEnabledFlow
를 collect
합니다. 이때 collector
는 아래에서 만든 torchEnabledCollector
으로 지정합니다.
@ObservedObject private var torchEnabledCollector = TorchEnabledCollector()
init() {
torchController.isEnabledFlow.collect(collector: isTorchEnabled, completionHandler: { error in
print("torchEnabledCollector ERROR: \(String(describing: error))")
})
}
마지막으로 해당 torchEnabledCollector
를 텍스트로 노출해 줍니다.
var body: some View {
VStack {
Text("Torch Enabled: \(String(describing: torchEnabledCollector.isEnabled))")
...
}.alert(...) {...}
}
최종적으로 iOS 코드는 다음과 같이 구현되게 됩니다.
struct ContentView: View {
private let torchController = TorchController()
@ObservedObject private var torchEnabledCollector = TorchEnabledCollector()
@State private var isVisibleTorchError = false
init() {
torchController.isEnabledFlow.collect(collector: torchEnabledCollector, completionHandler: { error in
print("torchEnabledCollector ERROR: \(String(describing: error))")
})
}
var body: some View {
VStack {
Text("Torch Enabled: \(String(describing: torchEnabledCollector.isEnabled))")
Button("Turn On", action: {
do {
try torchController.turnOn()
} catch {
isVisibleTorchError = true
print("turn-on ERROR: \(String(describing: error))")
}
})
Button("Turn Off", action: {
do {
try torchController.turnOff()
} catch {
isVisibleTorchError = true
print("turn-off ERROR: \(String(describing: error))")
}
})
}.alert(isPresented: $isVisibleTorchError) {
Alert(
title: Text("ERROR: TORCH NOT FOUND"),
message: Text("This device does not have a camera with a flash."),
dismissButton: .default(Text("OK"))
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class TorchEnabledCollector: Kotlinx_coroutines_coreFlowCollector, ObservableObject {
@Published var isEnabled = false
@MainActor func emit(value: Any?) async throws {
isEnabled = value as? Bool? == true
}
}
코틀린 멀티플랫폼 토치앱 실행해 보기
자 이제 코틀린 멀티플랫폼으로 만드는 안드로이드/iOS 토치앱은 완료되었습니다.
컴포즈 멀티플랫폼으로 만들기 전 잘 작동되는지 테스트해 보겠습니다.
Android
iOS
안드로이드와 iOS 모두 정상 작동됨을 확인하였으니 이제 컴포즈 멀티플랫폼으로 만들어보겠습니다.
컴포즈 멀티플랫폼 만들기
가장 먼저 작업해야 하는 부분은 Gradle 설정입니다. 이 부분이 간단하면서도 복잡한데, 저도 처음 작업할 때 이 부분이 가장 막막하였습니다. gradle.properites 설정은 물론 AGP 버전에 따라 설정하는 방법도 다르기에 무작정 다른 포스팅을 보고 따라 하시면 오류가 뿜뿜 뿜어져 나올 수 있을 것입니다. 저도 이 때문에 처음 컴포즈 멀티플랫폼 개발 시 정말 수개월 동안 많은 고생을 하였는데 깔끔하게 정리해 보겠습니다.
컴포즈 플러그인
컴포즈를 사용하기 위해서는 당연하게 컴포즈를 사용할 수 있도록 추가해야 합니다. 아직 Compose iOS는 Alpha 버전이기에 iOS에서도 사용가능한 컴포즈 멀티플랫폼을 받아오기 위해 dev maven을 추가합니다.
settings.gradle.kts
pluginManagement {
repositories {
...
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
dependencyResolutionManagement {
repositories {
...
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
Project - build.gradle.kts
allprojects {
repositories {
...
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
maven을 추가하였다면 이제 컴포즈를 사용하도록 plugin에 컴포즈를 등록해야 하는데, 이때 코틀린, 컴포즈, AGP 버전을 맞추어야 합니다. 컴포즈와 코틀린 버전이 맞지 않는다면 호환되지 않는다거나 AGP 버전 업그레이드 혹은 다운그레이드가 필요하다는 오류가 발생할 수 있습니다.
Kotlin - Compose 호환 (Kotlin compatibility)
Kotlin version | Minimal Compose version | Notes |
---|---|---|
1.5.31 | 1.0.0 | |
1.6.20 | 1.1.1 | |
1.7.10 | 1.2.0 | |
1.7.20 | 1.2.0 | JS is not supported (fixed in the 1.2.1) |
1.7.20 | 1.2.1 | |
1.8.0 | 1.3.0 | 1.3.0 is not supported by earlier k/native versions |
1.8.10 | 1.3.1 | |
1.8.20 | 1.4.0 | |
1.8.21 | 1.4.3 | |
1.8.22 | 1.4.3 | |
1.9.0 | 1.4.3 |
특정 버전을 사용하지 않으시다면 아래를 그대로 사용하시면 됩니다.
plugins {
id("com.android.application").version("8.1.0").apply(false)
id("com.android.library").version("8.1.0").apply(false)
kotlin("android").version("1.8.21").apply(false)
kotlin("multiplatform").version("1.8.21").apply(false)
id("org.jetbrains.compose").version("1.4.3").apply(false)
}
다만 여기서 재밌는 점은 Kotlin 1.8.21에 AGP 8.0.0을 사용하면 Project update recommended
알림이 표시되고, 8.1.0을 사용하면 테스트가 되지 않았다는 경고로 Kotlin Multiplatform <-> Android Gradle Plugin compatibility issue
가 발생하는 무한의 굴레에 빠지게 됩니다.
AGP 8.0.0
Project update recommended
Android Gradle plugin version 8.0.0 has an upgrade available. Start the AGP Upgrade Assistant to update this project's AGP version.
AGP 8.1.0
Kotlin Multiplatform <-> Android Gradle Plugin compatibility issue: The applied Android Gradle Plugin version (8.1.0) is higher than the maximum known to the Kotlin Gradle Plugin. Tooling stability in such configuration isn't tested, please report encountered issues to https://kotl.in/issue
Minimum supported Android Gradle Plugin version: 4.1
Maximum tested Android Gradle Plugin version: 8.0
To suppress this message add 'kotlin.mpp.androidGradlePluginCompatibility.nowarn=true' to your gradle.properties
이때 8.1.0의 경우에는 gardle.properties
에 아래를 추가하시면 경고를 생략할 수 있게 됩니다.
gradle.properties
kotlin.mpp.androidGradlePluginCompatibility.nowarn=true
안전성을 택한다면 8.0.0을 사용하시면 되고, 최신 AGP 사용을 원하신다면 8.1.0을 선택하시면 됩니다. 이 포스팅에서는 최신 버전인 8.1.0을 택하였습니다.
마지막으로 App 단 build.gradle.kts에 org.jetbrains.compose
를 추가해 주면 컴포즈 사용을 위한 플러그인 설정은 완료됩니다.
App - build.gradle.kts
plugins {
...
id("org.jetbrains.compose")
}
properties 버전 관리
당연하게도 properties로 버전 관리를 원하신다면 다음과 같이 하실 수 있습니다. 이 내용은 컴포즈 멀티플랫폼을 사용하기 위한 필수 내용은 아니기에 생략 가능합니다.
gradle.properites
#Versions
kotlin.version=1.8.21
agp.version=8.1.0
compose.version=1.4.3
settings.gradle.kts
plugins {
val kotlinVersion = extra["kotlin.version"] as String
val agpVersion = extra["agp.version"] as String
val composeVersion = extra["compose.version"] as String
kotlin("multiplatform").version(kotlinVersion)
kotlin("android").version(kotlinVersion)
id("com.android.application").version(agpVersion)
id("com.android.library").version(agpVersion)
id("org.jetbrains.compose").version(composeVersion)
}
Project - build.gradle.kts
plugins {
id("com.android.application").apply(false)
id("com.android.library").apply(false)
kotlin("android").apply(false)
kotlin("multiplatform").apply(false)
id("org.jetbrains.compose").apply(false)
}
Android/iOS 공통 컴포즈 화면 만들기
위의 과정이 끝났다면 iOS에서 사용할 컴포즈를 만들 수 있게 됩니다. Android와 iOS 화면을 각각 다른 컴포즈로 만들어 사용할 수 도 있고, 같은 화면을 그대로 사용할 수 도 있습니다. 여기서의 목표는 플러터, 리액트 네이티브와 같이 최대한 크로스플랫폼처럼 개발하기 위해 같은 화면을 사용하도록 하겠습니다.
여러 플랫폼에서 사용할 수 있도록 commonMain
에 MainScreen.kt
를 만들고 안드로이드에서 만들었던 컴포즈 코드를 그대로 복사하여 넣습니다. 이때 torchController
는 Android/iOS에 따라 constructor가 다르기에 common 모듈에서 생성하여 바로 사용할 수 없습니다. 그렇기에 파라미터로 넘겨받도록 합니다.
@Composable
fun MainScreen(torchController: TorchController) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val isTorchEnabled by torchController.isEnabledFlow.collectAsState(initial = false)
var isVisibleTorchError by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Torch Enabled: $isTorchEnabled")
Button(onClick = {
try {
torchController.turnOn()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn On")
}
Button(onClick = {
try {
torchController.turnOff()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn Off")
}
}
AnimatedVisibility(visible = isVisibleTorchError) {
AlertDialog(
onDismissRequest = { isVisibleTorchError = false },
confirmButton = {
Button(onClick = { isVisibleTorchError = false }) {
Text(text = "OK")
}
},
title = {
Text(text = "ERROR: TORCH NOT FOUND")
},
text = {
Text(text = "This device does not have a camera with a flash.")
}
)
}
}
}
그러나 사실 처음 만들었던 안드로이드의 컴포즈 화면을 그대로 사용하는 것은 불가능합니다. 위의 단계를 진행하는 경우 아무리 시도하더라도 AlertDialog가 import 되지 않을 텐데, 그 이유는 AlertDialog의 경우 Android를 의존하고 있기에 iOS에서는 사용이 불가능합니다. 즉 안드로이드의 AlertDialog 대신 직접 만들어 사용하거나 해당 기능을 제거해야 합니다.
2023년 8월 28일에 출시된 컴포즈 멀티플랫폼 1.5.0부터는 AlertDialog를 지원하기에 커스텀하여 구현하지 않아도 됩니다.
Compose Multiplatform 1.5.0 Release
Alert 만들기
저는 Android, iOS에서 각각 사용할 Alert을 만들도록 하겠습니다. Alert의 경우에도 같은 화면을 사용하도록 구현해도 되고, 혹은 플랫폼에 따라 다른 Alert을 그려도 됩니다. 이번에는 Android와 iOS 별개의 Alert 화면을 만들겠습니다.
우선 expect로 사용할 Alert 함수를 예상하고 이후 각 플랫폼 별 actual을 통해 실제 함수를 구현합니다.
commonMain/Alert.kt
@Composable
expect fun Alert(title: String, text: String, confirmText: String, onConfirm: () -> Unit)
Android의 경우에는 기존 AlertDialog
를 사용하였습니다.
androidMain/Alert.android.kt
@Composable
actual fun Alert(title: String, text: String, confirmText: String, onConfirm: () -> Unit) {
AlertDialog(
onDismissRequest = { },
confirmButton = {
Button(onClick = onConfirm) {
Text(text = confirmText)
}
},
title = {
Text(text = title)
},
text = {
Text(text = text)
},
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
iOS에서는 기존 iOS Alert 디자인을 참고하여 비슷하게 만들었습니다. 원래라면 색상이나 폰트를 테마 혹은 상수로 지정하여 사용하겠지만, 여기서는 하드코딩으로 사용하였습니다.
iosMain/Alert.ios.kt
@Composable
actual fun Alert(title: String, text: String, confirmText: String, onConfirm: () -> Unit) {
Column(
modifier = Modifier
.padding(48.dp)
.clickable(
onClick = {},
interactionSource = remember { MutableInteractionSource() },
indication = null
)
.background(color = Color(0xEEF2F2F2), shape = RoundedCornerShape(14.dp))
) {
Column {
Column(
modifier = Modifier.padding(vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 12.dp),
color = Color.Black,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = text,
modifier = Modifier.padding(top = 6.dp).padding(horizontal = 16.dp),
color = Color.Black,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center
)
}
Divider(color = Color(0xFFBABABA), thickness = 1.dp)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp))
.clickable(onClick = onConfirm)
.padding(horizontal = 8.dp, vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = confirmText,
color = Color(0xFF007AFF),
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
이제 직접 만든 Alert을 AlertDialog 대신 사용하면 Android와 iOS에서 MainScreen을 사용할 수 있게 됩니다. 저는 추가적으로 enter, exit에 애니메이션을 넣어 덜 어색하도록 구현하였습니다.
scaleIn, scaleOut 함수의 경우, This is an experimental animation API. 경고 발생 시 OptIn(ExperimentalAnimationApi::class)을 사용하여 해결합니다.
AnimatedVisibility(
visible = isVisibleTorchError,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut()
) {
Alert(
title = "ERROR: TORCH NOT FOUND",
text = "This device does not have a camera with a flash.",
confirmText = "OK",
onConfirm = {
isVisibleTorchError = false
}
)
}
최종 공통 컴포즈 화면
최종적으로 우리가 사용할 공통 컴포즈 화면은 다음과 같이 작성되게 됩니다. State Hoisting
을 따른다면 상태와 이벤트를 모두 파라미터로 두겠지만, 여기서는 생략하겠습니다.
commonMain/MainScreen.kt
@Composable
fun MainScreen(torchController: TorchController) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val isTorchEnabled by torchController.isEnabledFlow.collectAsState(initial = false)
var isVisibleTorchError by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Torch Enabled: $isTorchEnabled")
Button(onClick = {
try {
torchController.turnOn()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn On")
}
Button(onClick = {
try {
torchController.turnOff()
} catch (e: Exception) {
isVisibleTorchError = true
}
}) {
Text(text = "Turn Off")
}
}
AnimatedVisibility(
visible = isVisibleTorchError,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut()
) {
Alert(
title = "ERROR: TORCH NOT FOUND",
text = "This device does not have a camera with a flash.",
confirmText = "OK",
onConfirm = {
isVisibleTorchError = false
}
)
}
}
}
이제 마지막 단계입니다. 새롭게 만든 컴포즈 화면을 안드로이드와 iOS에 적용하도록 하겠습니다.
각 플랫폼에서 컴포즈 화면 사용하기
MainActivity에서 MainScreen
함수를 바로 사용하여도 괜찮지만 통일성 있는 관리를 위해 MainUI.android, MainUI.ios.kt 파일을 만들어 사용하겠습니다.
Android
안드로이드에서는 단순히 Wrapping 해준다는 느낌으로 만들어만 주면 됩니다.
androidMain/MainUI.android.kt
@Composable
fun MainUI(torchController: TorchController) = MainScreen(torchController = torchController)
그리고 MainActivity
에서 기존 컴포즈 코드를 지우고 새로 만든 컴포즈 화면인 MainUI
에 torchController
만 넣어주면 안드로이드는 작업이 완료됩니다.
MainActivity
class MainActivity : ComponentActivity() {
private val torchController by lazy {
TorchController(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
MainUI(torchController = torchController)
}
}
}
}
iOS
iOS에서는 컴포즈 화면을 사용하기 위해서는 ComposeUIViewController
을 사용해야 합니다. 이를 통해 내부 스코프에 있는 컴포즈는 SwiftUI에서 사용할 수 있는 UIKit
의 UIViewController
로 변환되게 됩니다.
MainUI.ios.kt
fun MainUIViewController() = ComposeUIViewController {
val torchController = TorchController()
MainScreen(torchController = torchController)
}
마지막으로 iOS에서 해당 MainUIViewController
을 호출할 수 있도록 스위프트 코드를 수정해 주면 모든 작업이 완료됩니다.
ContentView.swift
import SwiftUI
import shared
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainUI_iosKt.MainUIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.all)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
실행 결과는 다음과 같습니다
Android 결과물
기본 화면 | 오류 Alert |
iOS 결과물
기본 화면 | 오류 Alert |
화면 꾸미기
너무 딱딱한 것 같아 디자인을 바꾸고 싶다면 자유롭게 수정하시면 됩니다.
아래의 코드는 디자인만 변경되었으며, 이외 기능적인 차이는 없습니다. 즉 이 섹션은 생략하셔도 됩니다.
@Composable
fun MainScreen(torchController: TorchController) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val isTorchEnabled by torchController.isEnabledFlow.collectAsState(false)
var isVisibleTorchError by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF212020)),
contentAlignment = Alignment.Center
) {
TextButton(
onClick = {
try {
if (isTorchEnabled)
torchController.turnOff()
else
torchController.turnOn()
} catch (e: Exception) {
isVisibleTorchError = true
}
},
modifier = Modifier
.fillMaxWidth(.45f)
.aspectRatio(1f)
.clip(CircleShape)
.then(
if (isTorchEnabled) Modifier.background(Color(0xFFFB8000)) else Modifier.background(
brush = Brush.verticalGradient(
listOf(
Color(0xFF333333),
Color(0xFF282828)
)
),
)
)
.border(
width = 2.dp,
color = Color.Black,
shape = CircleShape
),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Transparent,
contentColor = Color.White
),
contentPadding = PaddingValues(0.dp)
) {
Text(
text = if (isTorchEnabled) "ON" else "OFF",
fontSize = 28.sp
)
}
AnimatedVisibility(
visible = isVisibleTorchError,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut()
) {
Alert(
title = "ERROR: TORCH NOT FOUND",
text = "This device does not have a camera with a flash.",
confirmText = "OK",
onConfirm = {
isVisibleTorchError = false
}
)
}
}
}
}
Android
iOS
축하합니다!
이제 여러분들은 코틀린 멀티플랫폼과 컴포즈 멀티플랫폼으로 자유롭게 Android/iOS 개발하실 수 있습니다! 물론 실제 서비스를 구현을 하기 위해서는 저 역시도 더 깊게 배워야겠지만, 간단한 토이 프로젝트 정도는 만드실 수 있으실 것입니다. 여기에 나온 일부 기능, 기술은 조금의 설명을 추가하여 따로 포스팅할 예정이니 KMP에 관심 있으시다면 차후 올라올 내용들도 참고하시면 좋으실 것 같습니다.
이 글을 작성한 이유는 단순 연구 목적도 있었겠지만, 처음 코틀린 멀티플랫폼 그리고 컴포즈 멀티플랫폼을 개발하기 위해 정말 1년이 가까운 시간을 소비하였습니다. 최근에는 조금씩 관련 정보글이 늘어나고 있지만 당시에는 정말 자료를 찾기 힘든 사막과 같았고 저처럼 고생하지 않으셨으면 하는 마음으로 조금 더 설명을 첨언하여 글을 작성해 보았습니다.
이 모든 소스코드는 아래의 깃 레포지토리에서 확인 가능하며, 간간히 업데이트될 수 있기에 시간이 지나면 본 내용과 일부 달라질 수 있습니다. 그렇기에 각 섹션에 추가된 깃 링크를 들어가시면 아카이브 해둔 브랜치에서 현재 소스코드와 동일한 상태로 확인하실 수 있습니다.
이 문서를 작성하는 동안 기존 모바일은 Kotlin Multiplatform Mobile(KMM), 이외는 Kotlin Multiplatform(KMP)로 불려 KMM Torch로 프로젝트 이름을 정하였으나, 이제는 KMP 하나의 용어로 통합되었기에 이후 프로젝트를 시작하시는 분은 KMP 단어를 사용하시는 것을 권장드립니다.
'개발 > Kotlin' 카테고리의 다른 글
코틀린 멀티플랫폼: iOS 빌드 시 CTFont* 오류 해결 (0) | 2023.10.03 |
---|
상상하는 것을 소프트웨어로 구현하는 것을 좋아하는 청년
게시글이 마음에 드시나요? [ 공감❤️ ] 눌러주시면 큰 힘이 됩니다!