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: https://kotlinconf.com/talks/812400/

John Rodriguez

May 23, 2025

Transcript

154 slides
  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

More Decks by John Rodriguez

Other Decks in Technology