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

Scaling Productivity- How we have improved our dev experience

Scaling Productivity- How we have improved our dev experience

Over the last years the Freeletics engineering department grew, and so did our code base and business requirements. In this talk we will explain what we have introduced to help our Android engineers stay productive, reduce the time it takes to build new product features, while keeping the barrier of entry for new joiners as low as possible.

Join us to learn how we approach engineering productivity at Freeletics, how we measure productivity, lessons we have learned and principles we are applying (borrowed from the lean startup methodology).

No worries, we will not only stay theoretical. In fact, we will share concrete tactics and solutions on how we have solved real world productivity problems at Freeletics. Amongst others: how do we deal efficiently with dependency injection, how we have reduced repetitive tasks and the need of writing boilerplate code, in app navigation in a highly modularized repository while keeping build times acceptable, how an architecture tailored for productivity can accelerate teams without sacrificing maintainability or readability, speeding up writing efficient tests.

Gain guidance and inspiration from this talk on how you can improve your and your Android colleagues productivity.

63c8b0590595c3de58406678ef99a6bf?s=128

Hannes Dorfmann

July 07, 2022
Tweet

More Decks by Hannes Dorfmann

Other Decks in Programming

Transcript

  1. Scaling Productivity How we improved our dev experience

  2. Hannes Dorfmann Gabriel Ittner @gabrielittner @sockeqwe

  3. How do we measure Engineering Productivity? • You can't improve

    what you can't measure • Google: DORA • It depends! For Freeletics: • Reduce time to write testable and maintainable code • PR review time • Rework Rate / Number of bugs • Repetitive tasks • Time to onboard new joiners • Time to create a new mobile app release • Build times / CI cost
  4. Approach • Lean start up principles • Discovery • User

    interviews • Ship increments • Test things out in front of real users • Part time model: feature development + engineering productivity team member to feel real world pain
  5. The code base • 600 Gradle modules in a monorepo

    • Unidirectional data flows • Compose for anything new • Dagger + Anvil • AndroidX Navigation • Fragments because of legacy
  6. StateMachine Composable Action Navigator Events State

  7. StateMachine Composable Action Navigator Events State ViewModel Fragment

  8. StateMachine Composable Action Navigator Events State ViewModel Fragment Dagger

  9. Repetitive tasks • each new module requires a lot of

    classes Time to onboard new • di ff erent structure in modules • hard to navigate our code base Observations and opportunities Time to onboard Repetitive tasks
  10. Tooling

  11. droid new module /feature/example

  12. None
  13. class ExampleStateMachine @Inject constructor( ) : FlowReduxStateMachine<ExampleState, ExampleAction>() { init

    { spec { } } } class ExampleNavigator @Inject constructor() : NavEventNavigator() { } @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, ) { }
  14. Learnings • Start with simple easy to built tools •

    Developers will give feedback and want more when using it • Example for tools • Formatting • IDE templates • Taking screenshots/gifs from a device • Shorthands for common commands or Gradle tasks
  15. Repetitive tasks • developer still write lot of code to

    glue classes together Rework rate • prone to simple bugs (i.e. lifecycle) Observations and opportunities Rework Rate Repetitive tasks
  16. Eliminating boilerplate

  17. @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, )

    class ExampleStateMachine : FlowReduxStateMachine<ExampleState, ExampleAction>() Set up code
  18. @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, )

    class ExampleStateMachine : FlowReduxStateMachine<ExampleState, ExampleAction>() Set up code
  19. @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, )

    class ExampleStateMachine : FlowReduxStateMachine<ExampleState, ExampleAction>() How do they communicate? Set up code
  20. @Composable fun ExampleUiScreen(…): Unit { val state = produceState<ExampleState?>(initialValue =

    null) { stateMachine.state.collect { value = it } } if (state.value != null) { ExampleUi(state.value) { action -> coroutineScope.launch { stateMachine.dispatch(action) } } } } Set up code
  21. @Composable fun ExampleUiScreen(…): Unit { val state = produceState<ExampleState?>(initialValue =

    null) { stateMachine.state.collect { value = it } } if (state.value != null) { ExampleUi(state.value) { action -> coroutineScope.launch { stateMachine.dispatch(action) } } } } Set up code
  22. @Composable fun ExampleUiScreen(…): Unit { val state = produceState<ExampleState?>(initialValue =

    null) { stateMachine.state.collect { value = it } } if (state.value != null) { ExampleUi(state.value) { action -> coroutineScope.launch { stateMachine.dispatch(action) } } } } Set up code
  23. @Composable fun ExampleUiScreen(…): Unit { val state = produceState<ExampleState?>(initialValue =

    null) { stateMachine.state.collect { value = it } } if (state.value != null) { ExampleUi(state.value) { action -> coroutineScope.launch { stateMachine.dispatch(action) } } } } Set up code
  24. @Composable fun ExampleUiScreen(…): Unit { val state = produceState<ExampleState?>(initialValue =

    null) { stateMachine.state.collect { value = it } } if (state.value != null) { ExampleUi(state.value) { action -> coroutineScope.launch { stateMachine.dispatch(action) } } } } Set up code
  25. @Composable fun ExampleUiScreen(…): Unit { val state = produceState<ExampleState?>(initialValue =

    null) { stateMachine.state.collect { value = it } } if (state.value != null) { ExampleUi(state.value) { action -> coroutineScope.launch { stateMachine.dispatch(action) } } } } Set up code
  26. Observations and opportunities Rework rate • Common Fragment issues like

    issues with lifecycle • Tying logic to system components Rework Rate
  27. public class ExampleUiFragment : Fragment() { public override fun onCreateView(

    inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { return ComposeView(requireContext()).apply { setContent { ExampleUiScreen(...) } } } } Fragments
  28. public class ExampleUiFragment : Fragment() { public override fun onCreateView(

    inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { return ComposeView(requireContext()).apply { setContent { ExampleUiScreen(...) } } } } Fragments
  29. public class ExampleUiFragment : Fragment() { public override fun onCreateView(

    inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { return ComposeView(requireContext()).apply { setContent { ExampleUiScreen(...) } } } } Fragments
  30. 😕 Problems • Code that came from the template is

    hard to update afterwards • Parts of the template that are not meant to be modified will end up being modified anyways
  31. @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, )

    Template
  32. @ComposeFragment( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

    ) @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, ) Template → Codegen
  33. @ComposeFragment( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

    ) @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, ) Codegen
  34. @ComposeFragment( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

    ) @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, ) Codegen
  35. @ComposeFragment( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

    ) @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, ) Codegen
  36. Codegen for Views

  37. class ExampleRenderer( binding: ExampleViewBinding ): ViewRenderer<ExampleState, ExampleAction>(binding) { init {

    binding.button.setOnClickListener { sendAction(ExampleAction.ButtonClicked) } } override fun renderToView(state: ExampleState) { } } Codegen for Views
  38. class ExampleRenderer( binding: ExampleViewBinding ): ViewRenderer<ExampleState, ExampleAction>(binding) { init {

    binding.button.setOnClickListener { sendAction(ExampleAction.ButtonClicked) } } override fun renderToView(state: ExampleState) { } } Codegen for Views
  39. class ExampleRenderer( binding: ExampleViewBinding ): ViewRenderer<ExampleState, ExampleAction>(binding) { init {

    binding.button.setOnClickListener { sendAction(ExampleAction.ButtonClicked) } } override fun renderToView(state: ExampleState) { } } Codegen for Views
  40. class ExampleRenderer( binding: ExampleViewBinding ): ViewRenderer<ExampleState, ExampleAction>(binding) { init {

    binding.button.setOnClickListener { sendAction(ExampleAction.ButtonClicked) } } override fun renderToView(state: ExampleState) { } } Codegen for Views
  41. class ExampleRenderer( binding: ExampleViewBinding ): ViewRenderer<ExampleState, ExampleAction>(binding) { init {

    binding.button.setOnClickListener { sendAction(ExampleAction.ButtonClicked) } } override fun renderToView(state: ExampleState) { } } Codegen for Views
  42. @RendererFragment( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

    ) class ExampleRenderer( binding: ExampleViewBinding ): ViewRenderer<ExampleState, ExampleAction>(binding) { init { binding.button.setOnClickListener { sendAction(ExampleAction.ButtonClicked) } } override fun renderToView(state: ExampleState) { } } Codegen for Views
  43. Updating the generated code

  44. Updating the generated code Relative build time 100% 0% 50%

  45. Easy migrations

  46. @ComposeFragment( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

    ) Easy migrations
  47. @ComposeScreen( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

    ) Easy migrations
  48. Eliminating all the boilerplate

  49. StateMachine Composable Navigator Events ViewModel Fragment Dagger Action State

  50. StateMachine Composable Navigator Events ViewModel Fragment Dagger Action State

  51. StateMachine Composable Action Navigator Events State ViewModel Fragment Dagger

  52. @ScopeTo(ExampleScope::class) @ContributesSubcomponent( scope = ExampleScope::class, parentScope = AppScope::class, ) interface

    ExampleComponent { val stateMachine: ExampleStateMachine @ContributesSubcompont.Factory interface Factory { fun create(): ExampleComponent } @ContributesTo(AppScope::class) interface ParentComponent { fun exampleComponentFactory(): Factory } }
  53. @ScopeTo(ExampleScope::class) @ContributesSubcomponent( scope = ExampleScope::class, parentScope = AppScope::class, ) interface

    ExampleComponent { val stateMachine: ExampleStateMachine @ContributesSubcompont.Factory interface Factory { fun create(): ExampleComponent } @ContributesTo(AppScope::class) interface ParentComponent { fun exampleComponentFactory(): Factory } }
  54. @ScopeTo(ExampleScope::class) @ContributesSubcomponent( scope = ExampleScope::class, parentScope = AppScope::class, ) interface

    ExampleComponent { val stateMachine: ExampleStateMachine @ContributesSubcompont.Factory interface Factory { fun create(): ExampleComponent } @ContributesTo(AppScope::class) interface ParentComponent { fun exampleComponentFactory(): Factory } } @ComposeFragment( scope = ExampleScope::class, parentScope = AppScope::class, )
  55. @ScopeTo(ExampleScope::class) @ContributesSubcomponent( scope = ExampleScope::class, parentScope = AppScope::class, ) interface

    ExampleComponent { val stateMachine: ExampleStateMachine @ContributesSubcompont.Factory interface Factory { fun create(): ExampleComponent } @ContributesTo(AppScope::class) interface ParentComponent { fun exampleComponentFactory(): Factory } }
  56. class ExampleViewModel( parentComponent: ExampleComponent.ParentComponent, ) : ViewModel() { val component:

    ExampleComponent = parentComponent.exampleComponentFactory().create() }
  57. Surviving config changes

  58. class ExampleStateMachine @Inject constructor( ) : FlowReduxStateMachine<ExampleState, ExampleAction>() { init

    { spec { } } } class ExampleNavigator @Inject constructor() : NavEventNavigator() { } Surviving config changes
  59. @ScopeTo(ExampleScope::class) class ExampleStateMachine @Inject constructor( ) : FlowReduxStateMachine<ExampleState, ExampleAction>() {

    init { spec { } } } @ScopeTo(ExampleScope::class) class ExampleNavigator @Inject constructor() : NavEventNavigator() { } Surviving config changes
  60. @ScopeTo(ExampleScope::class) class ExampleStateMachine @Inject constructor( ) : FlowReduxStateMachine<ExampleState, ExampleAction>() {

    init { spec { } } } @ScopeTo(ExampleScope::class) class ExampleNavigator @Inject constructor() : NavEventNavigator() { } Surviving config changes
  61. Anvil

  62. @Module object ExampleModule { @Provides fun provideExampleApi(retrofit: Retrofit): ExampleApi =

    retrofit.create(ExampleApi::class) } interface ExampleRepository class RealExampleRepository @Inject constructor( api: ExampleApi ) : ExampleRepository Anvil
  63. @Module @ContributesTo(ExampleScope::class) object ExampleModule { @Provides fun provideExampleApi(retrofit: Retrofit): ExampleApi

    = retrofit.create(ExampleApi::class) } interface ExampleRepository class RealExampleRepository @Inject constructor( api: ExampleApi ) : ExampleRepository Anvil
  64. @Module @ContributesTo(ExampleScope::class) object ExampleModule { @Provides fun provideExampleApi(retrofit: Retrofit): ExampleApi

    = retrofit.create(ExampleApi::class) } interface ExampleRepository @ContributesBinding(ExampleScope::class, ExampleRepository::class) class RealExampleRepository @Inject constructor( api: ExampleApi ) : ExampleRepository Anvil
  65. @Module @ContributesTo(ExampleScope::class) object ExampleModule { @Provides fun provideExampleApi(retrofit: Retrofit): ExampleApi

    = retrofit.create(ExampleApi::class) } interface ExampleRepository @ContributesBinding(ExampleScope::class, ExampleRepository::class) class RealExampleRepository @Inject constructor( api: ExampleApi ) : ExampleRepository Anvil
  66. None
  67. Dagger interactions

  68. @Inject @ScopeTo(ExampleScope::class) @ContributesBinding(ExampleScope::class, ...) @Module @ContributesTo(ExampleScope::class) Dagger interactions

  69. Learnings • Start with simple easy to built tools •

    Iterate after trying out and seeing what works well • Isolating system components and code generation make updates easy
  70. Observations and opportunities PR Review Time • Domain layer logic

    is hard to read Rework rate • async. code is hard (i.e. cancelation, race conditions, ... ) • testing code is hard • building reusable logic is hard to get right • fragile code, edge cases PR review time Rework Rate
  71. ...

  72. Countdown Do Exercise 
 
 tick countdown over RepetitionExercise Rest

    ... tick click on screen click skip
  73. DSL

  74. class WorkoutStateMachine: FlowReduxStateMachine<WorkoutState, WorkoutAction>() { init { spec { inState<CountdownState>{

    onEnter{ state -> decreaseCountdownTimer(state) } } inState<RepetitionExercise>(){ on<ClickAction>(){ state -> moveToNextExercise(state) } } inState<RestingState>{ onEnter{ decreaseRestTimer() } on<SkipClickedAction>{ state -> moveToNextExercise(state) } } } } } github.com/freeletics/FlowRedux
  75. class WorkoutStateMachine: FlowReduxStateMachine<WorkoutState, WorkoutAction>() { init { spec { inState<CountdownState>{

    onEnter{ state -> decreaseCountdownTimer(state) } } inState<RepetitionExercise>(){ on<ClickAction>(){ state -> moveToNextExercise(state) } } inState<RestingState>{ onEnter{ decreaseRestTimer() } on<SkipClickedAction>{ state -> moveToNextExercise(state) } } } } } github.com/freeletics/FlowRedux
  76. class ExampleStateMachineTest { @Test fun `countdown decreases`() = runTest {

    val workout : Workout = ... val initialState = CountdownState(timeLeft = 3) val stateMachine = WorkoutStateMachine(workout, state) stateMachine.state.test { // Turbine assertEquals( CountdownState(3), awaitItem() ) assertEquals( CountdownState(2), awaitItem() ) assertEquals( CountdownState(1), awaitItem() ) assertEquals( RepetitionExerciseState(workout.exercises[0]), awaitItem() ) } } } github.com/cashapp/turbine
  77. How do we know that the DSL is good? User

    interviews!
  78. spec { inState<CountdownState>{ onEnter{ getState, setState -> decreaseCountdown() } }

    suspend fun decrementCountdown(getState : GetState, setState : SetState){ val state : CountdownState = getState() delay(1_000) setState( state.copy(timeLeft = state.timeLeft - 1) ) }
  79. 👩💻 Not intuitive!

  80. spec { inState<CountdownState>{ onEnter{ state : CountdownState -> decrementCountdown(state) }

    } suspend fun decrementCountdown( state: CountdownState ): ChangedState<WorkoutState> { delay(1_000) 
 return MutateState<CountdownState, WorkoutState> { this.copy(timeLeft = this.timeLeft - 1) } }
  81. spec { inState<CountdownState>{ onEnter{ state : CountdownState -> decrementCountdown(state) }

    } suspend fun decrementCountdown( state: State<CountdownState> ): ChangedState<WorkoutState> { delay(1_000) 
 return state.mutate { this.copy(timeLeft = this.timeLeft - 1) } }
  82. Take away • DSL are useful • Iterate! • User

    Interviews • Write docs
  83. • Navigation is hard to scale Time to onboard •

    Hard to know how to navigate to another teams feature Observations and opportunities Time to onboard
  84. Navigation

  85. • type safety • efficiently navigate in modularised code base

    😕 Problems
  86. safe-args

  87. data class TweetFragmentArgs( val itemId: Long = -1L ) {

    fun toBundle(): Bundle { } companion object { @JvmStatic fun fromBundle(bundle: Bundle): TweetFragmentArgs { } } } safe-args
  88. safe-args feature:feed feature:tweet feature:profile

  89. safe-args feature:feed feature:tweet feature:profile TweetArgs ProfileArgs

  90. safe-args feature:feed feature:tweet feature:profile Circular dependency TweetArgs ProfileArgs

  91. nav modules feature:feed feature:tweet feature:profile TweetArgs ProfileArgs

  92. nav modules feature:feed feature:tweet feature:profile TweetArgs ProfileArgs feature:feed:nav feature:tweet:nav feature:profile:nav

  93. nav modules feature:feed feature:tweet feature:profile feature:feed:nav feature:tweet:nav feature:profile:nav TweetArgs ProfileArgs

  94. Why not just one core module? feature:feed feature:tweet feature:profile core:nav

    TweetArgs ProfileArgs
  95. Why not just one core module? feature:feed feature:tweet feature:profile core:nav

    TweetArgs ProfileArgs
  96. Why not just one core module? feature:feed feature:tweet feature:profile core:nav

    TweetArgs ProfileArgs
  97. Why not just one core module? feature:feed feature:tweet feature:profile core:nav

    TweetArgs ProfileArgs
  98. Why not just one core module? feature:feed feature:tweet feature:profile core:nav

    core:foo core:bar
  99. Boilerplate?

  100. data class TweetFragmentArgs( val itemId: Long = -1L ) {

    fun toBundle(): Bundle { } companion object { @JvmStatic fun fromBundle(bundle: Bundle): TweetFragmentArgs { } } } Boilerplate?
  101. @Parcelize data class TweetFragmentRoute( val itemId: Long = -1L )

    : NavRoute Boilerplate?
  102. @Parcelize data class TweetFragmentRoute( val itemId: Long = -1L )

    : NavRoute Boilerplate?
  103. @Parcelize data class TweetFragmentRoute( val itemId: Long = -1L )

    : NavRoute Boilerplate?
  104. @Parcelize data class TweetFragmentRoute( val itemId: Long = -1L )

    : NavRoute Boilerplate?
  105. @Parcelize data class TweetFragmentRoute( val itemId: Long = -1L )

    : NavRoute Boilerplate?
  106. Boilerplate? fun NavController.navigateTo(route: NavRoute) { val args = Bundle().putParcelable("route", route)

    // call actual navigate } fun <T : NavRoute> Fragment.requireRoute() { return requireArguments().getParcelable<T>("route") }
  107. Learnings • Sometimes code gen is not the solution

  108. github.com/freeletics/flowredux github.com/freeletics/mad Thank you! @gabrielittner @sockeqwe