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.

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


    View Slide

  2. Hannes Dorfmann
    Gabriel Ittner
    @gabrielittner @sockeqwe

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  6. StateMachine Composable
    Action
    Navigator
    Events
    State

    View Slide

  7. StateMachine Composable
    Action
    Navigator
    Events
    State
    ViewModel Fragment

    View Slide

  8. StateMachine Composable
    Action
    Navigator
    Events
    State
    ViewModel Fragment
    Dagger

    View Slide

  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

    View Slide

  10. Tooling

    View Slide

  11. droid new module /feature/example

    View Slide

  12. View Slide

  13. class ExampleStateMachine @Inject constructor(


    ) : FlowReduxStateMachine() {


    init {


    spec {


    }


    }


    }
    class ExampleNavigator @Inject constructor() : NavEventNavigator() {


    }
    @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    ) {


    }


    View Slide

  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

    View Slide

  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

    View Slide

  16. Eliminating boilerplate

    View Slide

  17. @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    class ExampleStateMachine : FlowReduxStateMachine()
    Set up code

    View Slide

  18. @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    class ExampleStateMachine : FlowReduxStateMachine()
    Set up code

    View Slide

  19. @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    class ExampleStateMachine : FlowReduxStateMachine()
    How do they communicate?
    Set up code

    View Slide

  20. @Composable


    fun ExampleUiScreen(…): Unit {


    val state = produceState(initialValue = null) {


    stateMachine.state.collect { value = it }


    }


    if (state.value != null) {


    ExampleUi(state.value) { action ->


    coroutineScope.launch { stateMachine.dispatch(action) }


    }


    }


    }


    Set up code

    View Slide

  21. @Composable


    fun ExampleUiScreen(…): Unit {


    val state = produceState(initialValue = null) {


    stateMachine.state.collect { value = it }


    }


    if (state.value != null) {


    ExampleUi(state.value) { action ->


    coroutineScope.launch { stateMachine.dispatch(action) }


    }


    }


    }


    Set up code

    View Slide

  22. @Composable


    fun ExampleUiScreen(…): Unit {


    val state = produceState(initialValue = null) {


    stateMachine.state.collect { value = it }


    }


    if (state.value != null) {


    ExampleUi(state.value) { action ->


    coroutineScope.launch { stateMachine.dispatch(action) }


    }


    }


    }


    Set up code

    View Slide

  23. @Composable


    fun ExampleUiScreen(…): Unit {


    val state = produceState(initialValue = null) {


    stateMachine.state.collect { value = it }


    }


    if (state.value != null) {


    ExampleUi(state.value) { action ->


    coroutineScope.launch { stateMachine.dispatch(action) }


    }


    }


    }


    Set up code

    View Slide

  24. @Composable


    fun ExampleUiScreen(…): Unit {


    val state = produceState(initialValue = null) {


    stateMachine.state.collect { value = it }


    }


    if (state.value != null) {


    ExampleUi(state.value) { action ->


    coroutineScope.launch { stateMachine.dispatch(action) }


    }


    }


    }


    Set up code

    View Slide

  25. @Composable


    fun ExampleUiScreen(…): Unit {


    val state = produceState(initialValue = null) {


    stateMachine.state.collect { value = it }


    }


    if (state.value != null) {


    ExampleUi(state.value) { action ->


    coroutineScope.launch { stateMachine.dispatch(action) }


    }


    }


    }


    Set up code

    View Slide

  26. Observations and opportunities
    Rework rate

    • Common Fragment issues like issues with lifecycle

    • Tying logic to system components
    Rework Rate

    View Slide

  27. public class ExampleUiFragment : Fragment() {


    public override fun onCreateView(


    inflater: LayoutInflater,


    container: ViewGroup?,


    savedInstanceState: Bundle?,


    ): View {


    return ComposeView(requireContext()).apply {


    setContent {


    ExampleUiScreen(...)


    }


    }


    }


    }


    Fragments

    View Slide

  28. public class ExampleUiFragment : Fragment() {


    public override fun onCreateView(


    inflater: LayoutInflater,


    container: ViewGroup?,


    savedInstanceState: Bundle?,


    ): View {


    return ComposeView(requireContext()).apply {


    setContent {


    ExampleUiScreen(...)


    }


    }


    }


    }


    Fragments

    View Slide

  29. public class ExampleUiFragment : Fragment() {


    public override fun onCreateView(


    inflater: LayoutInflater,


    container: ViewGroup?,


    savedInstanceState: Bundle?,


    ): View {


    return ComposeView(requireContext()).apply {


    setContent {


    ExampleUiScreen(...)


    }


    }


    }


    }


    Fragments

    View Slide

  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


    View Slide

  31. @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    Template

    View Slide

  32. @ComposeFragment(


    scope = ExampleScope::class,


    parentScope = AppScope::class,


    stateMachine = ExampleStateMachine::class,


    )


    @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    Template → Codegen

    View Slide

  33. @ComposeFragment(


    scope = ExampleScope::class,


    parentScope = AppScope::class,


    stateMachine = ExampleStateMachine::class,


    )


    @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    Codegen

    View Slide

  34. @ComposeFragment(


    scope = ExampleScope::class,


    parentScope = AppScope::class,


    stateMachine = ExampleStateMachine::class,


    )


    @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    Codegen

    View Slide

  35. @ComposeFragment(


    scope = ExampleScope::class,


    parentScope = AppScope::class,


    stateMachine = ExampleStateMachine::class,


    )


    @Composable


    fun ExampleUi(


    state: ExampleState,


    sendAction: (ExampleAction) -> Unit,


    )
    Codegen

    View Slide

  36. Codegen for Views

    View Slide

  37. class ExampleRenderer(


    binding: ExampleViewBinding


    ): ViewRenderer(binding) {




    init {


    binding.button.setOnClickListener {


    sendAction(ExampleAction.ButtonClicked)


    }


    }


    override fun renderToView(state: ExampleState) {


    }


    }


    Codegen for Views

    View Slide

  38. class ExampleRenderer(


    binding: ExampleViewBinding


    ): ViewRenderer(binding) {




    init {


    binding.button.setOnClickListener {


    sendAction(ExampleAction.ButtonClicked)


    }


    }


    override fun renderToView(state: ExampleState) {


    }


    }


    Codegen for Views

    View Slide

  39. class ExampleRenderer(


    binding: ExampleViewBinding


    ): ViewRenderer(binding) {




    init {


    binding.button.setOnClickListener {


    sendAction(ExampleAction.ButtonClicked)


    }


    }


    override fun renderToView(state: ExampleState) {


    }


    }


    Codegen for Views

    View Slide

  40. class ExampleRenderer(


    binding: ExampleViewBinding


    ): ViewRenderer(binding) {




    init {


    binding.button.setOnClickListener {


    sendAction(ExampleAction.ButtonClicked)


    }


    }


    override fun renderToView(state: ExampleState) {


    }


    }


    Codegen for Views

    View Slide

  41. class ExampleRenderer(


    binding: ExampleViewBinding


    ): ViewRenderer(binding) {




    init {


    binding.button.setOnClickListener {


    sendAction(ExampleAction.ButtonClicked)


    }


    }


    override fun renderToView(state: ExampleState) {


    }


    }


    Codegen for Views

    View Slide

  42. @RendererFragment(


    scope = ExampleScope::class,


    parentScope = AppScope::class,


    stateMachine = ExampleStateMachine::class,


    )


    class ExampleRenderer(


    binding: ExampleViewBinding


    ): ViewRenderer(binding) {




    init {


    binding.button.setOnClickListener {


    sendAction(ExampleAction.ButtonClicked)


    }


    }


    override fun renderToView(state: ExampleState) {


    }


    }


    Codegen for Views

    View Slide

  43. Updating the generated code

    View Slide

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

    View Slide

  45. Easy migrations

    View Slide

  46. @ComposeFragment(


    scope = ExampleScope::class,


    parentScope = AppScope::class,


    stateMachine = ExampleStateMachine::class,


    )
    Easy migrations

    View Slide

  47. @ComposeScreen(


    scope = ExampleScope::class,


    parentScope = AppScope::class,


    stateMachine = ExampleStateMachine::class,


    )
    Easy migrations

    View Slide

  48. Eliminating all the
    boilerplate

    View Slide

  49. StateMachine Composable
    Navigator
    Events
    ViewModel Fragment
    Dagger
    Action
    State

    View Slide

  50. StateMachine Composable
    Navigator
    Events
    ViewModel Fragment
    Dagger
    Action
    State

    View Slide

  51. StateMachine Composable
    Action
    Navigator
    Events
    State
    ViewModel Fragment
    Dagger

    View Slide

  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


    }


    }

    View Slide

  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


    }


    }

    View Slide

  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,


    )

    View Slide

  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


    }


    }

    View Slide

  56. class ExampleViewModel(


    parentComponent: ExampleComponent.ParentComponent,


    ) : ViewModel() {


    val component: ExampleComponent =


    parentComponent.exampleComponentFactory().create()


    }


    View Slide

  57. Surviving config changes

    View Slide

  58. class ExampleStateMachine @Inject constructor(


    ) : FlowReduxStateMachine() {


    init {


    spec {


    }


    }


    }
    class ExampleNavigator @Inject constructor() : NavEventNavigator() {


    }
    Surviving config changes

    View Slide

  59. @ScopeTo(ExampleScope::class)


    class ExampleStateMachine @Inject constructor(


    ) : FlowReduxStateMachine() {


    init {


    spec {


    }


    }


    }
    @ScopeTo(ExampleScope::class)


    class ExampleNavigator @Inject constructor() : NavEventNavigator() {


    }
    Surviving config changes

    View Slide

  60. @ScopeTo(ExampleScope::class)


    class ExampleStateMachine @Inject constructor(


    ) : FlowReduxStateMachine() {


    init {


    spec {


    }


    }


    }
    @ScopeTo(ExampleScope::class)


    class ExampleNavigator @Inject constructor() : NavEventNavigator() {


    }
    Surviving config changes

    View Slide

  61. Anvil

    View Slide

  62. @Module


    object ExampleModule {




    @Provides


    fun provideExampleApi(retrofit: Retrofit): ExampleApi =


    retrofit.create(ExampleApi::class)


    }
    interface ExampleRepository


    class RealExampleRepository @Inject constructor(


    api: ExampleApi


    ) : ExampleRepository
    Anvil

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  66. View Slide

  67. Dagger interactions

    View Slide

  68. @Inject


    @ScopeTo(ExampleScope::class)


    @ContributesBinding(ExampleScope::class, ...)


    @Module @ContributesTo(ExampleScope::class)


    Dagger interactions

    View Slide

  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

    View Slide

  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

    View Slide

  71. ...

    View Slide

  72. Countdown
    Do Exercise


    tick
    countdown over
    RepetitionExercise Rest ...
    tick
    click on screen click skip

    View Slide

  73. DSL

    View Slide

  74. class WorkoutStateMachine: FlowReduxStateMachine() {


    init {


    spec {


    inState{


    onEnter{ state -> decreaseCountdownTimer(state) }


    }


    inState(){


    on(){ state -> moveToNextExercise(state) }


    }


    inState{


    onEnter{ decreaseRestTimer() }


    on{ state -> moveToNextExercise(state) }


    }


    }


    }


    }
    github.com/freeletics/FlowRedux

    View Slide

  75. class WorkoutStateMachine: FlowReduxStateMachine() {


    init {


    spec {


    inState{


    onEnter{ state -> decreaseCountdownTimer(state) }


    }


    inState(){


    on(){ state -> moveToNextExercise(state) }


    }


    inState{


    onEnter{ decreaseRestTimer() }


    on{ state -> moveToNextExercise(state) }


    }


    }


    }


    }
    github.com/freeletics/FlowRedux

    View Slide

  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

    View Slide

  77. How do we know that the DSL is good?


    User interviews!

    View Slide

  78. spec {


    inState{


    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) )


    }

    View Slide

  79. 👩💻 Not intuitive!

    View Slide

  80. spec {


    inState{


    onEnter{ state : CountdownState ->


    decrementCountdown(state)


    }


    }


    suspend fun decrementCountdown(


    state: CountdownState


    ): ChangedState {


    delay(1_000)

    return MutateState {


    this.copy(timeLeft = this.timeLeft - 1)


    }


    }

    View Slide

  81. spec {


    inState{


    onEnter{ state : CountdownState ->


    decrementCountdown(state)


    }


    }


    suspend fun decrementCountdown(


    state: State


    ): ChangedState {


    delay(1_000)

    return state.mutate {


    this.copy(timeLeft = this.timeLeft - 1)


    }


    }

    View Slide

  82. Take away
    • DSL are useful


    • Iterate!


    • User Interviews


    • Write docs

    View Slide

  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


    View Slide

  84. Navigation

    View Slide

  85. • type safety


    • efficiently navigate in modularised code base


    😕 Problems

    View Slide

  86. safe-args

    View Slide

  87. data class TweetFragmentArgs(


    val itemId: Long = -1L


    ) {


    fun toBundle(): Bundle {


    }


    companion object {


    @JvmStatic


    fun fromBundle(bundle: Bundle): TweetFragmentArgs {


    }


    }


    }


    safe-args

    View Slide

  88. safe-args
    feature:feed feature:tweet feature:profile

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  98. Why not just one core module?
    feature:feed feature:tweet feature:profile
    core:nav
    core:foo core:bar

    View Slide

  99. Boilerplate?

    View Slide

  100. data class TweetFragmentArgs(


    val itemId: Long = -1L


    ) {


    fun toBundle(): Bundle {


    }


    companion object {


    @JvmStatic


    fun fromBundle(bundle: Bundle): TweetFragmentArgs {


    }


    }


    }


    Boilerplate?

    View Slide

  101. @Parcelize


    data class TweetFragmentRoute(


    val itemId: Long = -1L


    ) : NavRoute


    Boilerplate?

    View Slide

  102. @Parcelize


    data class TweetFragmentRoute(


    val itemId: Long = -1L


    ) : NavRoute


    Boilerplate?

    View Slide

  103. @Parcelize


    data class TweetFragmentRoute(


    val itemId: Long = -1L


    ) : NavRoute


    Boilerplate?

    View Slide

  104. @Parcelize


    data class TweetFragmentRoute(


    val itemId: Long = -1L


    ) : NavRoute


    Boilerplate?

    View Slide

  105. @Parcelize


    data class TweetFragmentRoute(


    val itemId: Long = -1L


    ) : NavRoute


    Boilerplate?

    View Slide

  106. Boilerplate?
    fun NavController.navigateTo(route: NavRoute) {


    val args = Bundle().putParcelable("route", route)


    // call actual navigate


    }


    fun Fragment.requireRoute() {


    return requireArguments().getParcelable("route")


    }

    View Slide

  107. Learnings
    • Sometimes code gen is not the solution

    View Slide

  108. github.com/freeletics/flowredux


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

    View Slide