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. 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
  2. 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
  3. The code base • 600 Gradle modules in a monorepo

    • Unidirectional data flows • Compose for anything new • Dagger + Anvil • AndroidX Navigation • Fragments because of legacy
  4. 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
  5. class ExampleStateMachine @Inject constructor( ) : FlowReduxStateMachine<ExampleState, ExampleAction>() { init

    { spec { } } } class ExampleNavigator @Inject constructor() : NavEventNavigator() { } @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, ) { }
  6. 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
  7. 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
  8. @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, )

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

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

    class ExampleStateMachine : FlowReduxStateMachine<ExampleState, ExampleAction>() How do they communicate? Set up code
  11. @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
  12. @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
  13. @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
  14. @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
  15. @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
  16. @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
  17. Observations and opportunities Rework rate • Common Fragment issues like

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

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

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

    inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { return ComposeView(requireContext()).apply { setContent { ExampleUiScreen(...) } } } } Fragments
  21. 😕 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
  22. @ComposeFragment( scope = ExampleScope::class, parentScope = AppScope::class, stateMachine = ExampleStateMachine::class,

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

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

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

    ) @Composable fun ExampleUi( state: ExampleState, sendAction: (ExampleAction) -> Unit, ) Codegen
  26. class ExampleRenderer( binding: ExampleViewBinding ): ViewRenderer<ExampleState, ExampleAction>(binding) { init {

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

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

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

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

    binding.button.setOnClickListener { sendAction(ExampleAction.ButtonClicked) } } override fun renderToView(state: ExampleState) { } } Codegen for Views
  31. @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
  32. @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 } }
  33. @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 } }
  34. @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, )
  35. @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 } }
  36. class ExampleViewModel( parentComponent: ExampleComponent.ParentComponent, ) : ViewModel() { val component:

    ExampleComponent = parentComponent.exampleComponentFactory().create() }
  37. class ExampleStateMachine @Inject constructor( ) : FlowReduxStateMachine<ExampleState, ExampleAction>() { init

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

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

    init { spec { } } } @ScopeTo(ExampleScope::class) class ExampleNavigator @Inject constructor() : NavEventNavigator() { } Surviving config changes
  40. @Module object ExampleModule { @Provides fun provideExampleApi(retrofit: Retrofit): ExampleApi =

    retrofit.create(ExampleApi::class) } interface ExampleRepository class RealExampleRepository @Inject constructor( api: ExampleApi ) : ExampleRepository Anvil
  41. @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
  42. @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
  43. @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
  44. 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
  45. 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
  46. ...

  47. DSL

  48. 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
  49. 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
  50. 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
  51. 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) ) }
  52. 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) } }
  53. 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) } }
  54. • Navigation is hard to scale Time to onboard •

    Hard to know how to navigate to another teams feature Observations and opportunities Time to onboard
  55. data class TweetFragmentArgs( val itemId: Long = -1L ) {

    fun toBundle(): Bundle { } companion object { @JvmStatic fun fromBundle(bundle: Bundle): TweetFragmentArgs { } } } safe-args
  56. data class TweetFragmentArgs( val itemId: Long = -1L ) {

    fun toBundle(): Bundle { } companion object { @JvmStatic fun fromBundle(bundle: Bundle): TweetFragmentArgs { } } } Boilerplate?
  57. 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") }