[KMP] Koin과 Expect/Actual 패턴을 통해 네이티브 기능 구현하기(이미지 저장/공유)

이지훈·2025년 3월 18일
0

KMP

목록 보기
6/6
post-thumbnail

서론

기존의 Android 프로젝트에서 사용했던 이미지 관련 로직들(이미지 저장, 공유)과 같은 Android 의 context 를 필요로 하는, 플랫폼에 의존하는 기능들을 KMP 환경에 어떻게 migration 할 수 있는지, iOS 파트는 어떻게 구현하면 되는지 알아보도록 하겠다.

본론

우선 기존에 사용했던 이미지 저장/공유 관련 context 확장 함수들은 다음과 같다.

import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat.PNG
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.Locale

// 비트맵 이미지를 외부 앱으로 공유
fun Context.externalShareForBitmap(bitmap: ImageBitmap) {
    try {
        val file = File(bitmap.saveToDisk(this))
        val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)

        ShareCompat.IntentBuilder(this)
            .setStream(uri)
            .setType("image/png")
            .startChooser()
    } catch (e: Exception) {
        Timber.e("[externalShareFoBitmap] message: ${e.message}")
    }
}

// 비트맵 이미지를 URI 로 변환
fun Context.bitmapToFileUri(bitmap: ImageBitmap): Uri? {
    return try {
        val file = File(bitmap.saveToDisk(this))
        FileProvider.getUriForFile(this, "$packageName.provider", file)
    } catch (e: Exception) {
        Timber.e("Failed to convert bitmap to URI: ${e.message}")
        null
    }
}

// 이미지 URI 를 사용하여 이미지를 외부 앱으로 공유
fun Context.shareImage(imageUri: Uri) {
    try {
        ShareCompat.IntentBuilder(this)
            .setStream(imageUri)
            .setType("image/png")
            .startChooser()
    } catch (e: Exception) {
        Timber.e("Failed to share image: ${e.message}")
    }
}

// 비트맵 이미지를 앱의 캐시 디렉토리에 임시파일로 저장
// 저장된 파일의 절대 경로를 반환 
private fun ImageBitmap.saveToDisk(context: Context): String {
    val fileName = "shared_image_${System.currentTimeMillis()}.png"
    val cachePath = File(context.cacheDir, "images").also { it.mkdirs() }
    val file = File(cachePath, fileName)
    val outputStream = FileOutputStream(file)

    asAndroidBitmap().compress(PNG, 100, outputStream)
    outputStream.flush()
    outputStream.close()

    return file.absolutePath
}

// 비트맵 이미지를 기기의 갤러리에 저장 
// MediaStore API 를 사용하여 이미지를 저장하고, 갤러리에 접근 가능하게 함
fun Context.saveImageToGallery(bitmap: ImageBitmap) {
    try {
        val fileName = "bandalart_${System.currentTimeMillis()}.png"
        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.MIME_TYPE, "image/png")
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
                put(MediaStore.Images.Media.IS_PENDING, 1)
            }
        }

        val imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
        imageUri?.let { uri ->
            contentResolver.openOutputStream(uri)?.use { outputStream ->
                bitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, outputStream)
            }

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                contentValues.clear()
                contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
                contentResolver.update(uri, contentValues, null, null)
            }
        }
    } catch (e: Exception) {
        Timber.e("Failed to save image to gallery: ${e.message}")
    }
}

// URI 가 가리키는 이미지를 기기의 갤러리에 저장 
// 소스 URI 의 내용을 MediaStore 를 통해 갤러리로 복사 
fun Context.saveUriToGallery(imageUri: Uri) {
    try {
        val fileName = "bandalart_${System.currentTimeMillis()}.png"
        val contentValues = createContentValues(fileName)
        val destinationUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
            ?: return

        copyUriContent(imageUri, destinationUri)
        updatePendingStatus(destinationUri, contentValues)
    } catch (e: Exception) {
        Timber.e("Failed to save image to gallery: ${e.message}")
    }
}

// 갤러리에 이미지를 저장하기 위한 ContentValues 를 생성
private fun createContentValues(fileName: String) = ContentValues().apply {
    put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
    put(MediaStore.Images.Media.MIME_TYPE, "image/png")
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
        put(MediaStore.Images.Media.IS_PENDING, 1)
    }
}

// 소스 URI(sourceUri): 복사하려는 원본 이미지 파일의 위치를 가리킴 
// 캐시에 임시로 저장된 이미지나 앱 내부에 있는 이미지 파일의 위치일 수 있음
// ex) '앱내 임시 폴더에 있는 사진.png'의 주소

// 대상 URI(destinationUri): 이미지를 복사할 목적지 위치를 가리킴
// 해당 함수의 경우에는 MediaStore API를 통해 생성된 갤러리 내의 새 이미지 파일 위치
// ex) '사용자 갤러리 앱의 사진 폴더에 저장될 새로운 사진.png'의 주소

// 스트림을 열어 소스 URI 의 이미지 데이터를 대상 URI 로 복사 
private fun Context.copyUriContent(sourceUri: Uri, destinationUri: Uri) {
    contentResolver.openInputStream(sourceUri)?.use { input ->
        contentResolver.openOutputStream(destinationUri)?.use { output ->
            input.copyTo(output)
        }
    }
}

// Android Q 이상에서 IS_PENDING 플래그를 업데이트
// 이미지 저장이 완료됨을 시스템에게 알려 갤러리에 표시되도록 함
private fun Context.updatePendingStatus(uri: Uri, contentValues: ContentValues) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        contentValues.clear()
        contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
        contentResolver.update(uri, contentValues, null, null)
    }
}

URI(Uniform Resource Identifier) 는 리소스를 식별하는 문자열로. Android 에서 URI 는 파일, 이미지, 데이터베이스 레코드 등의 위치를 가리키는 데 사용된다.

위의 함수들을 Composable 함수 내에서 LocalContext.current 방식으로 context를 가져와 사용하였다.
(context 를 함수의 인자로 받을지, 확장 함수 형태로 만들지는 취향의 차이라고 생각한다.)

문제 발생

expect/actual 패턴을 통한 구현이, interface/impl 구조와 매우 흡사한 것으로 알고 있다.

그렇다면 expect(interface) 까진 정의할 수 있겠는데,,

import androidx.compose.ui.graphics.ImageBitmap
import com.eygraber.uri.Uri

// 이런 느낌으로? 
interface ImageHandler {
    fun externalShareForBitmap(bitmap: ImageBitmap)
    fun bitmapToFileUri(bitmap: ImageBitmap): Uri?
    fun shareImage(imageUri: Uri)
    fun saveImageToGallery(bitmap: ImageBitmap)
    fun saveUriToGallery(imageUri: Uri)
    fun saveBitmapToDisk(bitmap: ImageBitmap): String
}

actual 을 구현할때, 어떻게 AndroidMain(Android 플랫폼)에만 별도로 Context 주입이 가능한 것일까?

import 구문을 보면 기존에 Android 개발에서 사용하던 Uri 의존성(android.net.Uri) 과 다른 것을 확인할 수 있는데, commonMain 에선 Android 플랫폼 의존성을 사용할 수 없기 때문이다.
따라서 KMP 환경에서 사용할 수 있는 uri-kmp 라이브러리를 사용하였다.

대체 Context 를 어떻게 주입해줘야 할까?

문제 해결

Koin 공식문서에 이에 대한 설명이 자세하게 기재되어있는 것을 확인할 수 있었다.

https://insert-koin.io/docs/reference/koin-android/get-instances/

위 공식문서의 내용을 바탕으로, KMP 환경에서 Koin 을 통한 플랫폼별 의존성 주입 방식을 간단히 설명하고, 전체 코드를 확인해보도록 하겠다.

Koin 을 통한 KMP 환경에서 의존성 주입 방법

위에서 계속 언급하였듯, KMP 프로젝트에서는 expect/actual 패턴을 활용해 플랫폼별 의존성을 관리한다.

Android 의 Application 객체와 같은 같은 플랫폼 의존성을 가진 객체는 해당 플랫폼 모듈에서만 주입된다.

먼저 Android Application 클래스에서 Koin을 초기화할 때 다음과 같이 설정한다.

class BandalartApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        initKoin {
            // 안드로이드에서는 Application 인스턴스를 주입
            androidContext(this@BandalartApplication)
            // 기타 모듈 설정
        }
    }
}

commonMain(공통)내에 코드에서는 platformModule 을 expect 로 선언한다.

// commonMain 의 Modules.kt
expect val platformModule: Module

expect/actual 패턴의 경우 위의 처럼 클래스 타입 뿐만 아니라, 함수, 프로퍼티, typealias 등 다양한 요소들에 적용이 가능하다!

Android 에서는 actual 구현에 생성자 주입 방식으로 Application 객체를 주입한다.

// androidMain 의 Modules.android.kt
actual val platformModule = module {
    // ImageHandlerProvider 생성자에 Application 인스턴스 주입
    single { ImageHandlerProvider(androidApplication()) }
    // 기타 다른 Android 의존성들...
}

iOS 에서는 별도의 플랫폼 의존성을 주입하지 않는 구현을 제공한다.

// iosMain 의 Modules.ios.kt
actual val platformModule = module {
    // iOS 는 Application 객체가 없으므로 다른 파라미터 없이 생성
    single { ImageHandlerProvider() }
    // 기타 다른 iOS 의존성들
}

이 방식을 통해 공통 코드에서는 platformModule을 통해 플랫폼에 맞는 의존성을 주입받을 수 있다. 안드로이드에서는 생성자 주입 방식으로 Application 객체를 활용하고, iOS에서는 해당 플랫폼에 맞는 별도의 구현을 제공한다.

전체 코드 및 모듈 구조

└── composeApp
    └── src
        ├── androidMain
        │   └── di
        │       └── Modules.android.kt
        │   └── BandalartApplication.kt
        ├── commonMain
        │   └── core
        │       ├── common
        │       │   └── ImageHandlerProvider.kt
        │       ...
        │   └── di
        │       ├── initKoin.kt
        │       └── Modules.kt
        └── iosMain
            └── core
            │   └── common
            │       └── ImageHandlerProvider.ios.kt
            └── di
                └── Modules.ios.kt

Timber 의 경우 KMP 환경에서는 사용할 수 없기에, Timber 와 유사한 KMP Logging 라이브러리인 Napier 를 사용하였다.

initKoin.kt in commonMain

fun initKoin(config: KoinAppDeclaration? = null) {
    startKoin {
        config?.invoke(this)
        modules(appModule, platformModule)
    }
}

BandalartApplication.kt in androidMain

class BandalartApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        if (BuildConfig.DEBUG) {
            Napier.base(DebugAntilog())
        }

        initKoin {
            androidContext(this@BandalartApplication)
        }
		...
    }
}

Modules.kt in commonMain

// context 주입을 위한 platformModule 
expect val platformModule: Module
...

Modules.android.kt in androidMain

import com.nexters.bandalart.core.common.AppVersionProvider
import com.nexters.bandalart.core.common.ImageHandlerProvider
import com.nexters.bandalart.core.database.BandalartDatabaseFactory
import com.nexters.bandalart.core.datastore.BandalartDataStoreFactory
import org.koin.android.ext.koin.androidApplication
import org.koin.dsl.module

actual val platformModule =
    module {
        ...
        single { ImageHandlerProvider(androidApplication()) }
    }

Modules.ios.kt in iosMain

import com.nexters.bandalart.core.common.AppVersionProvider
import com.nexters.bandalart.core.common.ImageHandlerProvider
import com.nexters.bandalart.core.database.BandalartDatabaseFactory
import com.nexters.bandalart.core.datastore.BandalartDataStoreFactory
import org.koin.core.module.Module
import org.koin.dsl.module

actual val platformModule: Module
    get() =
        module {
            ...
            single { ImageHandlerProvider() }
        }

ImageHandlerProvider.kt in commonMain

import androidx.compose.ui.graphics.ImageBitmap
import com.eygraber.uri.Uri

expect class ImageHandlerProvider {
    fun externalShareForBitmap(bitmap: ImageBitmap)
    fun bitmapToFileUri(bitmap: ImageBitmap): Uri?
    fun shareImage(imageUri: Uri)
    fun saveImageToGallery(bitmap: ImageBitmap)
    fun saveUriToGallery(imageUri: Uri)
    fun saveBitmapToDisk(bitmap: ImageBitmap): String
}

ImageHandlerProvider.android.kt in androidMain

import android.app.Application
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import com.eygraber.uri.Uri
import io.github.aakira.napier.Napier
import android.graphics.Bitmap.CompressFormat.PNG
import kotlinx.datetime.Clock
import java.io.File
import java.io.FileOutputStream

import android.net.Uri as AndroidUri // Android 플랫폼용 Uri

actual class ImageHandlerProvider(private val context: Application) {
    private val contentResolver: ContentResolver get() = context.contentResolver

    actual fun externalShareForBitmap(bitmap: ImageBitmap) {
        try {
            val file = File(saveBitmapToDisk(bitmap))
            val androidUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)

            val intent = ShareCompat.IntentBuilder(context)
                .setStream(androidUri)
                .setType("image/png")
                .intent

            context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK))
        } catch (e: Exception) {
            Napier.e("[externalShareFoBitmap] message: ${e.message}")
        }
    }

    actual fun bitmapToFileUri(bitmap: ImageBitmap): Uri? {
        return try {
            val file = File(saveBitmapToDisk(bitmap))
            FileProvider.getUriForFile(context, "${context.packageName}.provider", file).toKmpUri()
        } catch (e: Exception) {
            Napier.e("Failed to convert bitmap to URI: ${e.message}")
            null
        }
    }

    actual fun shareImage(imageUri: Uri) {
        try {
            ShareCompat.IntentBuilder(context)
                .setStream(imageUri.toAndroidUri())
                .setType("image/png")
                .startChooser()
        } catch (e: Exception) {
            Napier.e("Failed to share image: ${e.message}")
        }
    }

    actual fun saveBitmapToDisk(bitmap: ImageBitmap): String {
        val fileName = "shared_image_${Clock.System.now().toEpochMilliseconds()}.png"
        val cachePath = File(context.cacheDir, "images").also { it.mkdirs() }
        val file = File(cachePath, fileName)
        val outputStream = FileOutputStream(file)

        bitmap.asAndroidBitmap().compress(PNG, 100, outputStream)
        outputStream.flush()
        outputStream.close()

        return file.absolutePath
    }

    actual fun saveImageToGallery(bitmap: ImageBitmap) {
        try {
            val fileName = "bandalart_${Clock.System.now().toEpochMilliseconds()}.png"
            val contentValues = ContentValues().apply {
                put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
                put(MediaStore.Images.Media.MIME_TYPE, "image/png")
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
                    put(MediaStore.Images.Media.IS_PENDING, 1)
                }
            }

            val imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
            imageUri?.let { uri ->
                contentResolver.openOutputStream(uri)?.use { outputStream ->
                    bitmap.asAndroidBitmap().compress(PNG, 100, outputStream)
                }

                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    contentValues.clear()
                    contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
                    contentResolver.update(uri, contentValues, null, null)
                }
            }
        } catch (e: Exception) {
            Napier.e("Failed to save image to gallery: ${e.message}")
        }
    }

    actual fun saveUriToGallery(imageUri: Uri) {
        try {
            val fileName = "bandalart_${Clock.System.now().toEpochMilliseconds()}.png"
            val contentValues = createContentValues(fileName)
            val destinationUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
                ?: return

            copyUriContent(imageUri.toAndroidUri(), destinationUri)
            updatePendingStatus(destinationUri, contentValues)
        } catch (e: Exception) {
            Napier.e("Failed to save image to gallery: ${e.message}")
        }
    }

    private fun createContentValues(fileName: String) = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
        put(MediaStore.Images.Media.MIME_TYPE, "image/png")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }
    }

    private fun copyUriContent(sourceUri: AndroidUri, destinationUri: AndroidUri) {
        contentResolver.openInputStream(sourceUri)?.use { input ->
            contentResolver.openOutputStream(destinationUri)?.use { output ->
                input.copyTo(output)
            }
        }
    }

    private fun updatePendingStatus(uri: AndroidUri, contentValues: ContentValues) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            contentValues.clear()
            contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
            contentResolver.update(uri, contentValues, null, null)
        }
    }

    // AndroidUri, KmpUri 호환을 위한 mapper 
    private fun AndroidUri.toKmpUri(): Uri = Uri.parse(toString())
    private fun Uri.toAndroidUri(): AndroidUri = AndroidUri.parse(toString())
}

iosMain 에서는 NS 타입UIImage 와 같은 Apple 의 Foundation 및 UIKit 프레임워크에서 제공하는 타입들을 사용하여 actual class 를 구현할 수 있다.

KMP 환경에서는 Objective-C 나 Swift 를 사용하지 않고도 이러한 iOS 네이티브 환경에서 사용하는 타입들을 Kotlin/Native를 통해 직접 호출하여, 플랫폼 특화 기능을 구현할 수 있는 것이 특징이다.

ImageHandlerProvider.ios.kt in iosMain

import androidx.compose.ui.graphics.ImageBitmap
import com.eygraber.uri.Uri
import io.github.aakira.napier.Napier
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import platform.CoreGraphics.CGBitmapContextCreate
import platform.CoreGraphics.CGBitmapContextCreateImage
import platform.CoreGraphics.CGColorSpaceCreateWithName
import platform.CoreGraphics.CGImageAlphaInfo
import platform.CoreGraphics.kCGBitmapByteOrder32Little
import platform.CoreGraphics.kCGColorSpaceSRGB
import platform.Foundation.NSDate
import platform.Foundation.NSString
import platform.Foundation.NSTemporaryDirectory
import platform.Foundation.NSURL
import platform.Foundation.stringByAppendingPathComponent
import platform.Foundation.timeIntervalSince1970
import platform.Foundation.writeToFile
import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication
import platform.UIKit.UIImage
import platform.UIKit.UIImagePNGRepresentation
import platform.UIKit.UIImageWriteToSavedPhotosAlbum

actual class ImageHandlerProvider {
    actual fun externalShareForBitmap(bitmap: ImageBitmap) {
        try {
            val image = bitmap.toUiImage()
            shareBitmap(image)
        } catch (e: Exception) {
            Napier.e("[externalShareFoBitmap] message: ${e.message}")
        }
    }

    actual fun bitmapToFileUri(bitmap: ImageBitmap): Uri? {
        return try {
            val filePath = saveBitmapToDisk(bitmap)
            NSURL.fileURLWithPath(filePath).absoluteString?.let { Uri.parse(it) }
        } catch (e: Exception) {
            Napier.e("Failed to convert bitmap to URI: ${e.message}")
            null
        }
    }

    actual fun shareImage(imageUri: Uri) {
        try {
            val nsUrl = NSURL.URLWithString(imageUri.toString()) ?: return
            shareUrl(nsUrl)
        } catch (e: Exception) {
            Napier.e("Failed to share image: ${e.message}")
        }
    }

    actual fun saveBitmapToDisk(bitmap: ImageBitmap): String {
        val fileName = "shared_image_${NSDate().timeIntervalSince1970}.png"
        val tempDir = NSTemporaryDirectory()
        val filePath = (tempDir as NSString).stringByAppendingPathComponent(fileName)

        val image = bitmap.toUiImage()
        if (image != null) {
            UIImagePNGRepresentation(image)?.writeToFile(filePath, true)
        }

        return filePath
    }

    @OptIn(ExperimentalForeignApi::class)
    actual fun saveImageToGallery(bitmap: ImageBitmap) {
        try {
            val image = bitmap.toUiImage()
            if (image != null) {
                UIImageWriteToSavedPhotosAlbum(image, null, null, null)
            }
        } catch (e: Exception) {
            Napier.e("Failed to save image to gallery: ${e.message}")
        }
    }

    @OptIn(ExperimentalForeignApi::class)
    actual fun saveUriToGallery(imageUri: Uri) {
        try {
            val nsUrl = NSURL.URLWithString(imageUri.toString()) ?: return
            val image = UIImage.imageWithContentsOfFile(nsUrl.path!!)
            if (image != null) {
                UIImageWriteToSavedPhotosAlbum(image, null, null, null)
            }
        } catch (e: Exception) {
            Napier.e("Failed to save image to gallery: ${e.message}")
        }
    }

    @OptIn(ExperimentalForeignApi::class)
    private fun ImageBitmap.toUiImage(): UIImage? {
        val buffer = IntArray(width * height)
        readPixels(buffer)

        // https://github.com/takahirom/roborazzi/blob/main/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt#L88C51-L88C68
        val colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB)
        val bitmapInfo = CGImageAlphaInfo.kCGImageAlphaPremultipliedFirst.value or kCGBitmapByteOrder32Little
        val context = CGBitmapContextCreate(
            data = buffer.refTo(0),
            width = width.toULong(),
            height = height.toULong(),
            bitsPerComponent = 8u,
            bytesPerRow = (4 * width).toULong(),
            space = colorSpace,
            bitmapInfo = bitmapInfo,
        )

        val cgImage = CGBitmapContextCreateImage(context)
        return cgImage?.let { UIImage.imageWithCGImage(it) }
    }

    private fun shareBitmap(bitmap: UIImage?) {
        bitmap ?: return
        val activityViewController = UIActivityViewController(
            activityItems = listOf(bitmap),
            applicationActivities = null,
        )
        UIApplication.sharedApplication.keyWindow?.rootViewController?.presentViewController(
            activityViewController,
            animated = true,
            completion = null,
        )
    }

    private fun shareUrl(nsUrl: NSURL) {
        val activityViewController = UIActivityViewController(
            activityItems = listOf(nsUrl),
            applicationActivities = null,
        )
        UIApplication.sharedApplication.keyWindow?.rootViewController?.presentViewController(
            activityViewController,
            animated = true,
            completion = null,
        )
    }
}

드로이드카이기 레포를 참고하여 graphicsLayer 를 활용해 화면의 특정 컴포저블 영역을 캡쳐하고, 이를 ImageBitmap 으로 변환하여 저장하는 방식을 구현하였다.

이후 ImageBitmap을 UiKit의 UIImage로 변환하는 로직 역시, 드로이드카이기 레포내 코드를 참고하여 적용할 수 있었다.

ImageBitmap -> UIImage 의 경우, 구글링을 통해 발견한 레퍼런스를 먼저 적용해보았으나, 색상이 바래지는 이슈가 발생하여, 해당 방식을 적용하지 않았다.
imageBitmap 과 UIImage 의 색상을 다루는 방식이 다르기에, 이를 보정해주는 로직이 함수내에 포함되어있는데, 보정 과정에서 어떤 문제가 발생한 것으로 추측할 수 있었다. 드로이드카이기는 신이다.

변환 전 변환 후

결론

KMP 환경에서 Koin 을 사용하여, Android/iOS 각 플랫폼에 의존하는 기능들을 어떻게 구현할 수 있는지, 그 방법을 확인할 수 있었다.

내 개인적인 소감을 말하자면 음... Android 관련 지식과 Koin 사용 방법에 어느정도 익숙하더라도, 전체 구현 완성의 난이도가 상당히 어렵고, 레퍼런스 또한 아직 많이 부족하다고 느꼈다.

ImageHandlerProvider.ios.kt 과 같은 actual class 를 구현하는 부분에서, 비록 Objective-C, swift 언어를 쓰지 않는다 할지라도, iOS 를 어느정도 알아야 구현이 가능하다는 것을 알 수 있었다.

iOS 를 어느정도 안다고 할지라도, Compose 관련 의존성인 ImageBitmap 등을 iOS 의 UIImage 로 변환하는 등의 방법등은 알기 어려운데, 변환을 하기위해선 각 플랫폼의 이미지 클래스가 내부적으로 어떤 방식으로 이미지를 처리하는지를 알아야하기 때문이다.
(메모리에서 픽셀 데이터가 어떻게 배치되는지, color space 는 어떻게 관리되는지, 알파 채널 처리 방식, 바이트 순서(엔디안)...)

이의 경우 ChatGPT 등의 LLM 의 도움을 받는다 할지라도, 관련 레퍼런스가 부족하기에 신뢰성있는 답변을 기대하긴 힘들다. 할루시네이션을 남발하기도 하였다...

레퍼런스들에서 제공하는 코드 구현체들에 대한 어느정도 이해가 가능하도록 iOS 관련 학습을 꾸준히 병행해야겠다.

전체 코드는 아래 레포에서 확인할 수 있습니다.
https://github.com/Nexters/BandalArt-KMP

레퍼런스)
https://github.com/DroidKaigi/conference-app-2024
https://insert-koin.io/docs/reference/koin-android/get-instances/

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글

관련 채용 정보