$30 off During Our Annual Pro Sale. View Details »

플레이어 SDK 개발자의 Kotlin Multiplatform 도입기

플레이어 SDK 개발자의 Kotlin Multiplatform 도입기

네이버 공통 플레이어 SDK에 KMP를 도입한 경험과 고민했던 내용을 공유합니다.
Droid Knights 2024 발표 자료입니다.

https://festa.io/events/4990

mojs | 모진섭

June 11, 2024
Tweet

Other Decks in Programming

Transcript

  1. ❏ 다양한 서비스의 여러 플랫폼을 지원 ❏ 서비스 기술 지원

    및 유지 보수가 팀 리소스의 많은 부분을 차지 ❏ 사업적으로 중요한 신규 과제를 원활하게 진행하기 어려움 팀 리소스 문제
  2. ❏ 기능 1개당 플랫폼 별 N벌 개발 ❏ 개발자와 비용이

    늘어 신규 플랫폼에 대한 확대 필요 시 의사 결정이 어려움 ❏ 각 플랫폼 개발자의 스펙에 대한 이해가 미묘하게 다름 ❏ 기능의 다양성을 만들어냄 -> 커뮤니케이션 비용 상승 중복 개발
  3. ❏ 당시 KMM (현 KMP) alpha 릴리즈 ❏ Android +

    iOS ❏ 때마침 Google Cast의 Custom Web Receiver 개발이 필요했음 ❏ Kotlin/JS로 개발하면서 점진적으로 공유 코드를 늘려 확장해보기로 함 ❏ 동시에 다른 플랫폼도 Prototype으로 검증 Kotlin Multiplatform?!
  4. external class CastReceiverContext { ... fun start(options: CastReceiverOptions = definedExternally):

    CastReceiverContext fun stop() companion object { fun getInstance(): CastReceiverContext } }
  5. /** * Manages loading of underlying libraries and initializes underlying

    cast receiver SDK. * @see https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.CastReceiverContext */ export class CastReceiverContext { /** * Returns the CastReceiverContext singleton instance. */ static getInstance(): CastReceiverContext; ... /** * Initializes system manager and media manager; so that receiver app can receive requests from senders. */ start(options?: CastReceiverOptions): CastReceiverContext; /** * Shutdown receiver application. */ stop(): void; }
  6. external open class CastReceiverContext { ... open fun start(options: CastReceiverOptions

    = definedExternally): CastReceiverContext open fun stop() companion object { fun getInstance(): CastReceiverContext } }
  7. class Formatter(val name: String) { fun format(n: Int): String {

    return "$name: $n" } fun format(o: Any): String { return "$name: $o" } }
  8. @JsExport class Formatter(val name: String) { fun format(n: Int): String

    { return "$name: $n" } fun format(o: Any): String { return "$name: $o" } }
  9. @JsExport class Formatter(val name: String) { @JsName("formatInt") fun format(n: Int):

    String { return "$name: $n" } fun format(o: Any): String { return "$name: $o" } }
  10. function Formatter(name) { this.name_1 = name; } Formatter.prototype.get_name_woqyms_k$ = function

    () { return this.name_1; }; Formatter.prototype.formatInt = function (n) { return this.name_1 + ': ' + n; }; Formatter.prototype.format = function (o) { return this.name_1 + ': ' + toString(o); }; Formatter.$metadata$ = classMeta('Formatter'); Object.defineProperty(Formatter.prototype, 'name', { configurable: true, get: Formatter.prototype.get_name_woqyms_k$ });
  11. function Formatter(name) { this.name_1 = name; } Formatter.prototype.get_name_woqyms_k$ = function

    () { return this.name_1; }; Formatter.prototype.formatInt = function (n) { return this.name_1 + ': ' + n; }; Formatter.prototype.format = function (o) { return this.name_1 + ': ' + toString(o); }; Formatter.$metadata$ = classMeta('Formatter'); Object.defineProperty(Formatter.prototype, 'name', { configurable: true, get: Formatter.prototype.get_name_woqyms_k$ });
  12. function Formatter(name) { this.name_1 = name; } Formatter.prototype.get_name_woqyms_k$ = function

    () { return this.name_1; }; Formatter.prototype.formatInt = function (n) { return this.name_1 + ': ' + n; }; Formatter.prototype.format = function (o) { return this.name_1 + ': ' + toString(o); }; Formatter.$metadata$ = classMeta('Formatter'); Object.defineProperty(Formatter.prototype, 'name', { configurable: true, get: Formatter.prototype.get_name_woqyms_k$ });
  13. @JsExport class Formatter(@get:JsName("key") val name: String) { @JsName("formatInt") fun format(n:

    Int): String { return "$name: $n" } fun format(o: Any): String { return "$name: $o" } }
  14. function Formatter(name) { this.name_1 = name; } Formatter.prototype.key = function

    () { return this.name_1; }; Formatter.prototype.formatInt = function (n) { return this.name_1 + ': ' + n; }; Formatter.prototype.format = function (o) { return this.name_1 + ': ' + toString(o); }; Formatter.$metadata$ = classMeta('Formatter'); Object.defineProperty(Formatter.prototype, 'name', { configurable: true, get: Formatter.prototype.key });
  15. function Formatter(name) { this.name_1 = name; } Formatter.prototype.key = function

    () { return this.name_1; }; Formatter.prototype.formatInt = function (n) { return this.name_1 + ': ' + n; }; Formatter.prototype.format = function (o) { return this.name_1 + ': ' + toString(o); }; Formatter.$metadata$ = classMeta('Formatter'); Object.defineProperty(Formatter.prototype, 'name', { configurable: true, get: Formatter.prototype.key });
  16. class Formatter { constructor(name) { return new.target.new_Formatter_p3ihin_k$(name); } static new_Formatter_p3ihin_k$(name,

    $box) { var $this = (0,_kotlin_kotlin_stdlib_mjs__WEBPACK_IMPORTED_MODULE_0__.createThis2j2avj17cvnv2)(this, $box); $this.name = name; return $this; } key() { return this.name; } formatInt(n) { return this.name + ': ' + n; } format(o) { return this.name + ': ' + (0,_kotlin_kotlin_stdlib_mjs__WEBPACK_IMPORTED_MODULE_0__.toString1pkumu07cwy4m)(o); } }
  17. sealed interface State sealed interface Event interface Player { val

    state: StateFlow<State> val events: Flow<Event> }
  18. sealed interface State sealed interface Event interface Source interface Player

    { val state: StateFlow<State> val events: Flow<Event> suspend fun load(source: Source) }
  19. class MediaPlayerImpl : Player { private val player = MediaPlayer.create(context,

    uri, surfaceHolder) override fun play() { player.start() } }
  20. class ExoPlayerImpl : Player { private val player = ExoPlayer.Builder(context).build().apply

    { setVideoSurfaceHolder(surfaceHolder) setMediaItem(MediaItem.fromUri(uri)) prepare() } override fun play() { player.playWhenReady = true } }
  21. class AVPlayerImpl: Player { private let player: AVPlayer = {

    let playerItem = AVPlayerItem(url: url) let player = AVPlayer(playerItem: playerItem) playerLayer.player = player return player }() func play() { player.play() } }
  22. // PlatformPlayer.android.kt actual class PlatformPlayer : Player { private val

    player = ExoPlayerImpl() override fun play() { player.play() } }
  23. class AVPlayerImpl : Player { private val player: AVPlayer =

    createPlayer() private fun createPlayer(): AVPlayer { val playerItem = AVPlayerItem(uRL = url) val player = AVPlayer(playerItem = playerItem) playerLayer.player = player return player } override fun play() { player.play() } }
  24. // PlatformPlayer.ios.kt actual class PlatformPlayer : Player { private val

    player = AVPlayerImpl() override fun play() { player.play() } }
  25. ❏ HLS 스트리밍 중 미디어 관련 정보를 metadata로 전달 ❏

    이것에 활용되는 ID3 metadata를 파싱하기 위한 ID3 tag 파서 구현 필요 ❏ 텍스트 관련 ID3 Frame 중 첫 번째 Byte는 encoding을 나타냄 Timed Metadata for HLS
  26. ❏ kotlin-stdlib는 ByteArray -> UTF-8 String 변환 기능 제공 ❏

    그 외 다른 encoding에 대한 지원은 없었음 Kotlin 문자열 디코딩
  27. /** * Decodes a string from the bytes in UTF-8

    encoding in this array. * * Malformed byte sequences are replaced by the replacement char `\uFFFD`. */ @SinceKotlin("1.4") @WasExperimental(ExperimentalStdlibApi::class) public expect fun ByteArray.decodeToString(): String
  28. ❏ kotlinx-io는 obsolete 였음 ❏ 게다가 모든 플랫폼에 대한 UTF-16,

    ISO-8859-1 디코딩 지원하지 않았음 ❏ 이 부분을 직접 구현하기로 함 Kotlin 문자열 디코딩
  29. internal fun Charset.toNSStringEncoding(): NSStringEncoding = when (this) { Charsets.UTF_8 ->

    NSUTF8StringEncoding Charsets.UTF_16 -> NSUTF16StringEncoding Charsets.UTF_16BE -> NSUTF16BigEndianStringEncoding Charsets.UTF_16LE -> NSUTF16LittleEndianStringEncoding Charsets.ISO_8859_1 -> NSISOLatin1StringEncoding else -> throw UnsupportedEncodingException("Charset '${this.name}' is not supported.") } internal actual fun CharsetDecoder.decodeToStringImpl( input: ByteArray, fromIndex: Int, toIndex: Int, ): String { val encoding = _charset.toNSStringEncoding() val inputArray = if (fromIndex == 0 && toIndex == input.size) input else input.copyOfRange(fromIndex, toIndex) val data = inputArray.toNSData() @Suppress("CAST_NEVER_SUCCEEDS") return NSString.create(data, encoding) as? String ?: throw IOException("Failed to decode bytes. charset: $charset") }
  30. ❏ 하나의 테스트 코드만 작성해도 모든 플랫폼 테스트 가능 ❏

    플랫폼별 동작을 맞추기 용이 하나의 테스트 코드를 멀티플랫폼으로
  31. ❏ KMP 적용 범위는 선택하기 나름 ❏ 일부 코어 로직만

    공유 ❏ UI 상태 관리를 포함한 비즈니스 로직 공유 ❏ UI까지 Kotlin으로 작성 꼭 모든 것을 공유하지 않아도 된다
  32. ❏ Compose Multiplatform ❏ UI까지 Kotlin으로 작성하여 여러 플랫폼 지원

    ❏ Kotlin/wasm (O), Kotlin/JS (X) ❏ 플레이어 SDK에도 UI는 있다 ❏ UI 컴포넌트, 데모앱 모든 것을 공유할 수도 있다
  33. ❏ Android는 Compose, iOS는 Swift UI ❏ Web은 Kotlin/JS를 쓰거나

    직접 Typescript로 구현 ❏ 선택의 폭이 넓다 UI만 각 플랫폼 native 방식으로 구현할 수 있다
  34. ❏ 각 플랫폼 특화된 개발자들이 파트별로 구성 ❏ 기능 1개당

    플랫폼별 N벌 개발 더 효율적인 팀 리소스 운영
  35. ❏ 멀티플랫폼을 도입해도 여전히 플랫폼 특화 개발자 필요 ❏ 하지만

    공유 코드에 리소스 집중 가능 ❏ 플랫폼별 코드 중복을 줄이고 통합 더 효율적인 팀 리소스 운영
  36. ❏ 설계 및 스펙 차이로 플랫폼 간 커뮤니케이션 ❏ 각

    플랫폼에서 개별적으로 다른 팀과 논의 더 효율적인 커뮤니케이션
  37. ❏ KMP 프로젝트 구성을 위한 새로운 툴 ❏ 기존 빌드

    시스템은 모듈, 플랫폼, 종속성 변경이 있을 때 세팅이 오래걸림 ❏ YAML을 활용하여 선언적으로 프로젝트를 구성할 수 있음 ❏ 아직 초기 인큐베이팅 단계 Amper
  38. ❏ Frederick Brooks가 1986년에 발표한 논문 ❏ ʻ소프트웨어 개발의 본질적인

    어려움을 해결할 단 하나의 기적적인 해결책은 없다’ ❏ KMP 또한 좋은 도구지만, 모든 문제를 해결해주진 못함 No Silver Bullet
  39. ❏ 멀티플랫폼 도입에 회의적인 팀원은 분명 있다 ❏ 누군가는 새로운

    환경을 즐기지만, 그렇지 않은 사람도 있다 ❏ 리더 설득하기? ❏ 우리 팀은 운 좋게도 먼저 제안을.. 팀원 설득하기
  40. ❏ 팀 리소스 문제로 시작했던 KMP 도입 ❏ KMP 도입

    자체에도 많은 리소스가 필요 ❏ 업무 우선순위에 따라 뒤로 밀리는 일이 부지기수 다시 팀 리소스 문제
  41. ❏ 기존 플레이어 구조를 개선하고 싶었으나 호환성 때문에 시도하기 어려움

    ❏ KMP 도입하면서 다 뜯어고쳐보자?! ❏ 개발 볼륨이 너무 커짐 + 다시 돌아온 리소스 문제 더 잘 하려는 욕심..?
  42. ❏ 기존 프로젝트에서 마이그레이션 한다면 적용 범위에 대한 고민 필수

    ❏ 공유 코드를 활용한 작은 제품 출시 ❏ 작은 공통 모듈 만들어 적용해보기 작게 시작하자
  43. ❏ 멀티플랫폼 꼭 필요한가요? ❏ 여기서 No를 외친다면 필요 없음

    ❏ 프로젝트의 크기, 팀원 구성, 공감대에 따라 선택 KMP 도입해도 괜찮은가?
  44. ❏ 팀원 구성 ❏ Android 개발자 등 Kotlin에 익숙한 팀원이

    적극적으로 참여 필요 ❏ 그래야만 원활한 진행 가능 ❏ Kotlin에 익숙한 개발자가 적은 경우 진행이 더딜 가능성이 높음 KMP 도입해도 괜찮은가?
  45. ❏ 새로운 기술에 대한 열린 자세 ❏ Android 외 타

    플랫폼 개발자도 Kotlin에 대해 알아가고 싶은 마음이 있어야 ❏ 반대로 Android 개발자도 타 플랫폼에 열린 자세 ❏ 시작은 Kotlin만으로 가능하겠지만, 결국 각 플랫폼 전문 지식 필요 ❏ Kotlin은 이를 잘 엮어주는 역할 KMP 도입해도 괜찮은가?