Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Duolingo + KMP: A Case Study in Developer Produ...

Duolingo + KMP: A Case Study in Developer Productivity (KotlinConf 2025)

Duolingo ships weekly on iOS and Android to 40M+ Daily Active Users across 176 countries. Shipping fast is important to us and Kotlin Multiplatform is starting to play a huge role in how we accomplish this!

In this talk, we’ll:

* Discuss how we shipped Video Call, Math, Adventures and more across Android, iOS, and Web
* Propose what potential upcoming projects could benefit from KMP and the rubric we use
* Share the challenges faced and lessons learned along the way

…and more!

Co-Presented with @johnnyeet.

Video: coming soon!

Avatar for John Rodriguez

John Rodriguez

May 23, 2025
Tweet

More Decks by John Rodriguez

Other Decks in Technology

Transcript

  1. Duolingo + KMP : A Case Study in Developer Productivity

    Johnny Ye & John Rodriguez @ johnnyeet @jrodbx
  2. data class ExperimentRecord( val eligible: Boolean, val condition: String, val

    treated: Boolean, val contexts: Set<String>, ) rollout 0% false
  3. data class ExperimentRecord( val eligible: Boolean, val condition: String, val

    treated: Boolean, val contexts: Set<String>, ) rollout 20% true
  4. data class ExperimentRecord( val eligible: Boolean, val condition: String, val

    treated: Boolean, val contexts: Set<String>, ) "control" "experiment"
  5. data class ExperimentRecord( val eligible: Boolean, val condition: String, val

    treated: Boolean, val contexts: Set<String>, ) "control" "exp1" "exp2"
  6. data class ExperimentRecord( val eligible: Boolean, val condition: String, val

    treated: Boolean, val contexts: Set<String>, ) setOf("backend", "frontend") ?
  7. data class ExperimentRecord( val eligible: Boolean, val condition: String, val

    treated: Boolean, val contexts: Set<String>, ) @objc public class ExperimentRecord: NSObject { @objc public let eligible: Bool @objc public let condition: String @objc public let treated: Bool @objc public let contexts: Set<String> }
  8. data class TreatmentUpdate( val experimentName: String, val contexts: Set<String>, val

    condition: String, ) public struct ExperimentUpdate { public let experimentName: ExperimentName public let contexts: Set<String> public let treated = true public func addingContext(_ context: String?) -> ExperimentUpdate { if let context { return addingContexts([context]) } else { return self } } public func addingContexts( _ newContexts: some Sequence<String> ) -> ExperimentUpdate {
  9. class TreatmentApiClient( s: NativeNetworkStubs, engine: HttpClientEngine ) : TreatmentSender {

    private val client = HttpClient(engine) { install(s) } fun sendTreatment(treatment: TreatmentUpdate): ExperimentRecord { val response = client.patch( s.buildUrl("/users/${s.userId}/experiments/${treatment.name}") ) { headers { append(HttpHeaders.ContentType, "application/json") } setBody(serialize(treatment).toString()) } return Json.decodeFromString(response.bodyAsText()) }
  10. class ExperimentNetworkClient: ExperimentNetworkService { let networkClient: NetworkClient func treat( userID:

    DUOUserID, inExperiment experimentName: ExperimentName, contexts: Set<String>, completion: @escaping (CommonResult<ExperimentRecord>) -> Void ) { let route = UpdateExperimentTreatment( contexts: Array(contexts), experimentName: experimentName, treated: true, userId: userID ) networkClient.fireRequest(route, completion: completion) } }
  11. NativeFile JsonBlobFile JsonBlobTreatmentQueue TreatmentApiClient QueuedTreatmentSender ExperimentsManager ExperimentRepository kotlinx.serialization ktor NativeUserModelStubs

    NativeNetworkStubs val userId: Long, val experiments: Map<ExperimentID, ExperimentRecord> val userId: Long val baseUrl: Url val userAgent: String val jwtToken: String
  12. class AdventuresStateEngine() { val sceneStateFlow: MutableStateFlow<SceneState?> = MutableStateFlow(null) fun onSceneTouchEvent(event:

    GridTouchEvent) fun onFrameRefresh(elapsedTime: Duration) fun onGoalButtonClicked() fun onPlayerChoice( choiceId: OptionId, updateChallengeStats: () -> Unit ) }
  13. class AdventuresStateEngine() { val sceneStateFlow: MutableStateFlow<SceneState?> = MutableStateFlow(null) fun onSceneTouchEvent(event:

    GridTouchEvent) fun onFrameRefresh(elapsedTime: Duration) fun onGoalButtonClicked() fun onPlayerChoice( choiceId: OptionId, updateChallengeStats: () -> Unit ) }
  14. class AdventuresStateEngine() { val sceneStateFlow: MutableStateFlow<SceneState?> = MutableStateFlow(null) fun onSceneTouchEvent(event:

    GridTouchEvent) fun onFrameRefresh(elapsedTime: Duration) fun onGoalButtonClicked() fun onPlayerChoice( choiceId: OptionId, updateChallengeStats: () -> Unit ) }
  15. class AdventuresStateEngine() { val sceneStateFlow: MutableStateFlow<SceneState?> = MutableStateFlow(null) fun onSceneTouchEvent(event:

    GridTouchEvent) fun onFrameRefresh(elapsedTime: Duration) fun onGoalButtonClicked() fun onPlayerChoice( choiceId: OptionId, updateChallengeStats: () -> Unit ) }
  16. func onSceneGesture(_ sender: UILongPressGestureRecognizer) { guard let screenGridHelper else {

    return } let point = sender.location(in: sceneView.adventuresScene) let gridCoords = screenGridHelper.screenToGridF(screenCoordinates: PointF( x: Float(point.x), y: Float(point.y) ) ) let gridTouchEvent = GridTouchEvent( x: GridUnit(n: gridCoords.x), y: GridUnit(n: gridCoords.y), ) stateEngine.onSceneTouchEvent(event: gridTouchEvent) }
  17. class AdventuresStateEngine() { fun onSceneTouchEvent(event: GridTouchEvent) { when (event.action) {

    GridTouchEvent.Action.UP -> { if (state.mode != SceneMode.ROAM) { return@updateState state } onTileSelected(eventTile) } } }
  18. fun walkCharacterTo(character: InstanceId, destination: Point) { updateState { state -

    > val source = state.objects[character] ?: return@updateState state val bestPath = findBestPath( state, source, mapOf(destination to 0) ) setNewPath(bestPath) state } }
  19. class AdventuresStateEngine() { fun onFrameRefresh(elapsedTime: Duration) { updateState { state

    - > handleDelayedUpdates() walk(elapsedTime) animateSpeechBubble() animateCameraUpdate(elapsedTime) animateLayoutUpdate(elapsedTime) updateNudgeIfNeeded() state.copy(currentTime = state.currentTime + elapsedTime) } } }
  20. class AdventuresStateEngine() { fun walk(elapsedTime: Duration) { updateState { startingState

    - > val nextPathingPosition = currentPath.first() if (state.player.facing == nextPathingPosition.facing) { // walking } else { // rotating } state } } }
  21. fun animateSpeechBubble() { updateState { state - > val prevSpan

    = ttsSpans.lastOrNull { it.startTime.seconds <= elapsedTime } val currentRange = prevSpan.startIndex until prevSpan.endIndex } state.copy( speechBubbles = mapOf( speaker to bubble.copy( text = bubbleText.copy(audioSpan = currentRange))) ) } }
  22. data class ScreenGridHelper( val tileHeight: Float, val tileWidth: Float, val

    gridOrigin: PointF, val environmentBounds: Rect, ) { // Maps a position on the grid to screen coordinates fun gridToScreen(gridCoordinates: Point): Point // Maps a tile on the grid to its bounding rectangle on the screen fun getTileScreenBounds(gridCoordinates: Point): Rect }
  23. extension AdventuresSceneView { /// Sets the given RiveView's position, hide/unhide,

    z position, etc. /// according to given layout. func setObjectViewLayout( object: AdventuresObjectModel, gridHelper: ScreenGridHelper ) { // Calculate view rect let p1 = gridHelper.gridToScreenF(gridCoordinates: corner) let p2 = gridHelper.gridToScreenF(gridCoordinates: otherCorner) let rect = CGRect( origin: CGPoint(x: CGFloat(p1.x), y: CGFloat(p2.y)), size: CGSize( width: CGFloat(p2.x - p1.x), height: CGFloat(p1.y - p2.y) ) riveView.frame = rect } )
  24. fun interface Closeable { fun close() } class CFlow<T>(private val

    origin: Flow<T>) : Flow<T> by origin { fun watch(block: (T) -> Unit): Closeable { val job = Job() onEach { block(it) } .launchIn(CoroutineScope(Dispatchers.Unconfined + job)) return Closeable { job.cancel() } } } fun <T : Any> Flow<T>.wrap(): CFlow<T> = CFlow(this)
  25. @JsExport class AdventuresEngineHelper(private val engine: AdventuresStateEngine) { var state: SceneState?

    = null val scope = CoroutineScope(Dispatchers.Unconfined) fun observeEngine() { scope.launch { engine.observeState().collect { value - > state = value } } } }
  26. 9 months iOS + KMP 5 months Time = time

    per engineer × # of engineers
  27. 9 months Web 5 months 1.5 months Time = time

    per engineer × # of engineers
  28. • AI-speaking feature • Build on iOS first • Wanted

    to communicate with backend via WebSockets video call
  29. class VideoCallSession { val webSocketSessionManager = WebSocketSessionManager<UnifiedRequestMessage, UnifiedResponseMessage> fun receiveMessage(message:

    UnifiedResponseMessage) { ... } fun create(createSessionRequest: CreateSessionRequest) { ... } fun interrupt(interruptEvent: InterruptEvent) { ... } fun tts(ttsEvent: TtsEvent) { ... } fun end(endSessionRequest: EndSessionRequest) { ... } }
  30. class VideoCallSession { val webSocketSessionManager = WebSocketSessionManager<UnifiedRequest, UnifiedResponse> fun interrupt(interruptEvent:

    InterruptEvent) { webSocketSessionManager.sendMessage( InterruptEventMessage(interruptEvent) ) } }
  31. func sendInterruptionEvent(_ interruptionEvent: VideoCallInterruptEvent) throws { let handle: VideoCallSession =

    try self.getRunningHandle() handle.interrupt(interruptEvent: interruptionEvent) }
  32. • Mostly maintained by Android devs • Easier to debug

    for Backend devs developer experience
  33. 9 months KMP / WebSockets 1 month Time = time

    per engineer × # of engineers
  34. 9 months Android 1 month 6 months Time = time

    per engineer × # of engineers
  35. class MathGrader { fun gradePlacePoint( userGuess: MathEntity.Point, gradingSpecification: GradingSpecification, ):

    GradingResult = PlacePointChallengeGrader.grade(userGuess, gradingSpecification) }
  36. class MathGrader { fun gradePlacePoint( userGuess: MathEntity.Point, gradingSpecification: GradingSpecification, ):

    GradingResult = PlacePointChallengeGrader.grade(userGuess, gradingSpecification) } fun gradedAsEqual(other: MathEntity): Boolean { return this == other }
  37. class MathGrader { fun gradePlacePoint( userGuess: MathEntity.Point, gradingSpecification: GradingSpecification, ):

    GradingResult = PlacePointChallengeGrader.grade(userGuess, gradingSpecification) }
  38. class MathGrader { fun gradePolygon( userGuess: MathEntity.Polygon, gradingSpecification: GradingSpecification, ):

    PolygonGradingResult = PolygonChallengeGrader.grade(userGuess, gradingSpecification) }
  39. class MathGrader { fun gradeFactorTree( userGuess: RiveHandle, gradingSpecification: GradingSpecification, ):

    GradingResult = FactorTreeChallengeGrader.grade(userGuess, gradingSpecification) }
  40. object FactorTreeChallengeGrader { fun grade(userGuess, gradingSpec): GradingResult { val fields

    = gradingSpec.answerFormat.type.indexFields val treeNodeForTile = fields.map { userGuess.getNumber(property = it) } val isCorrect = isCorrectByGradingSpec( listOf(treeNodeForTile.map { Integer(it) }), gradingSpec, ) if (isCorrect) { return GradingResult(isCorrect, feedbackMessage = null) } val correctNode = closestCorrectAnswer(treeNodeForTile, gradingSpec.rules) val first = firstIncorrectBlankIndex(treeNodeForTile, correctNode) val feedbackMessage = (gradingSpec.gradingFeedback as FactorTree) .factorTreeFeedback .feedbackOptions .getOrNull(first) return GradingResult(isCorrect = false, feedbackMessage = feedbackMessage) } } 0 1 2 3 4 0 1 2 3 4
  41. • Our most recent course • iOS first, KMP for

    Android port • KMP + Rive to Drive UI
  42. data class ChessGameState( val chessBoardState: ChessBoardState, val setupModel: ChessGameSetupModel, val

    userColor: ChessPlayerColor = ChessPlayerColor.WHITE, val currentPlayer: ChessPlayerColor = ChessPlayerColor.WHITE, val moveHistory: List<ChessMove> = emptyList(), val selectedPosition: ChessCoordinate? = null, val validMovesForSelectedPosition: List<ChessMove> = emptyList(), ... )
  43. data class ChessGameState( val chessBoardState: ChessBoardState, val setupModel: ChessGameSetupModel, val

    userColor: ChessPlayerColor = ChessPlayerColor.WHITE, val currentPlayer: ChessPlayerColor = ChessPlayerColor.WHITE, val moveHistory: List<ChessMove> = emptyList(), val selectedPosition: ChessCoordinate? = null, val validMovesForSelectedPosition: List<ChessMove> = emptyList(), ... ) { fun getValidMoveFromString(moveString: String): ChessMove? { .. . } fun getEligibleMovesForPosition( position: ChessCoordinate, color: ChessPlayerColor? = null, ): List<ChessMove> { ... }
  44. public class ChessRiveUseCase(dispatcher: CoroutineDispatcher) { private val coroutineScope = CoroutineScope(SupervisorJob()

    + dispatcher) private lateinit var gameUseCase: ChessGameUseCase public fun initializeGame(setupModel: ChessGameSetupModel) { gameUseCase = ChessGameUseCase(setupModel) chessGameState = gameUseCase.gameState.value } private var chessGameState: ChessGameState? = null set(value) { value ?. let { state - > field = value updateCurrentPlayerColor(state.currentPlayer) setKingState() updateRiveAssetDataForGameState(state) } }
  45. public class ChessRiveUseCase(dispatcher: CoroutineDispatcher) { private val coroutineScope = CoroutineScope(SupervisorJob()

    + dispatcher) private lateinit var gameUseCase: ChessGameUseCase public fun initializeGame(setupModel: ChessGameSetupModel) { gameUseCase = ChessGameUseCase(setupModel) chessGameState = gameUseCase.gameState.value } private var chessGameState: ChessGameState? = null set(value) { value ?. let { state - > field = value updateCurrentPlayerColor(state.currentPlayer) setKingState() updateRiveAssetDataForGameState(state) } }
  46. 20 months Android 1 month 12 months* *estimate Time =

    time per engineer × # of engineers
  47. class ModularRiveEngine { private fun handleEventIfNeeded() { private fun handleSubtitleEvent(event:

    SubtitleEvent) { updateState { state - > state.copy(currentSubtitle = event.text) } } }
  48. func updateSubtitle(with newState: ModularRiveState) { let oldSubtitle = state.currentSubtitle let

    newSubtitle = newState.currentSubtitle guard let newSubtitle, let subtitleRiveVM = riveMapping[ModularRiveSubtitleVM.subtitleId], oldSubtitle != newSubtitle else { // Subtitle did not change return } subtitleRiveVM.triggerInput( "enter_trig", path: nextSubtitleArtboard.rawValue ) }
  49. 1 month Android Time = time per engineer × #

    of engineers 2 weeks 1 week
  50. Started iOS Android experiments-lib Nov 2023 - - adventures Dec

    2023 Mar 2024 n/a video-call Jul 2024 Q3 2024 Q4 2024 math-grading Dec 2024 Jan 2025 (partial) Jan 2025 (partial) chess Q1 2025 Q2 2025 soon! maker Q2 2025 Q2 2025 Q2 2025