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

Flutter at scale

Flutter at scale

Flutter has been out of beta for less than a year. It is an amazing framework UI that lets you run your code pretty much anywhere. There's a lot of expectations around it, incredible apps running at 60fps, but... is it ready to be adopted at scale? At BMW, we faced that question a while ago, and the answer was YES! We tried it, we loved it, and we collected all the necessary information to prove that Flutter was the right choice for us. In this talk, let me show you our process to make Flutter our preferred tool for developing applications, and how we enable multiple teams around the world to deliver incredible experiences fast and safely.

Jorge Coca

August 27, 2019
Tweet

More Decks by Jorge Coca

Other Decks in Programming

Transcript

  1. Flutter at
    scale
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  2. Before we start
    Our Journey to Flutter
    - Android Summit 2019 -
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  3. What do you mean by
    Flutter at scale?
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  4. Complex problems,
    easy solutions.
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  5. @jcocaramos - Droidcon NYC 2019

    View full-size slide

  6. Goals before
    code
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  7. Business and design goals
    We want to be able to regularly release our
    products across all brands, all platforms,
    and all regions simultaneously, with the
    same feature capabilities
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  8. Engineering goals
    Create a platform that is developer
    friendly, developer scalable, and
    performant, which provides safe
    experimentation and continuos
    deployment.
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  9. @jcocaramos - Droidcon NYC 2019

    View full-size slide

  10. Release regularly
    → Train releases: every X weeks, whether you are
    ready or not
    → Trunk-based development: all code is on master
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  11. All brands, regions, and platforms
    → BMW, BMWi, MINI, Rolls Royce
    → Global presence, but different legal/market
    requirements
    → One single Flutter codebase, multiple apps with
    different design libraries
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  12. Same feature capabilities
    → Feature flags to support different integrations,
    markets, and configurations
    → Cloud centric: reduce number of decisions on
    mobile, it is just a rendering client
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  13. Developer friendly
    → Tests. Tons of tests.
    → Consistent coding style
    → Tech debt is addressed soon
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  14. Developer scalable
    → Assume 1 to N developers
    → Natural onboarding
    → Keep it simple!
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  15. Performant
    → 60fps
    → 99.9% crash free
    → Measure, analyze, improve
    → Tests, tests, tests
    → Easy to identify where are the problems
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  16. Safe experimentation
    → Feature flags, A/B
    → Complexity out of the client
    → Predictable APIs, natural language
    → Variants are forced to be tested on integration
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  17. Continuos deployment
    → Merge a commit, you are in production!
    → Same philosophy on backend and client
    → Percentage rollout
    → Monitoring, alerting, and remote debugging
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  18. @jcocaramos - Droidcon NYC 2019

    View full-size slide

  19. How do we do
    this with
    Flutter?
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  20. Learn from
    the past
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  21. Our tools
    → High level architecture
    → flutter_bloc (@felangelov)
    → lumberdash (@bmw-tech)
    → unit, widget, and integration tests
    → ozzie (@bmw-tech)
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  22. Example
    Find more at felangel.github.io/
    bloc
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  23. High level
    architecture
    → Vertical modules (data, domain,
    UI & state)
    → Horizontal modules (feature
    composition)
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  24. Flutter is composable by nature
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  25. flutter_bloc
    A predictable state management library that helps
    implement the BLoC design pattern
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  26. UI = f (state)
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  27. UI = f (state)
    → UI is the layout of the screen
    → f is the build method of your widgets
    → state is the different scenarios that your
    application can handle
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  28. States
    Pure Dart classes used to
    describe a particular moment in
    the life cycle of the application.
    States can be tied to a UI screen
    (counter value) or not (user is
    authenticated)
    int counter;
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  29. UI Events
    Pure Dart classes (POJOs) used to
    describe actions triggered from
    the system/user
    enum CounterEvent {
    increment,
    decrement,
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  30. bloc
    Pure Dart class that receives events, and produces
    states.
    → Can be used in other Dart projects as well
    (Angular, CLI...)
    → Takes repositories as dependencies
    → Provides an initialState and a mapEventToState
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  31. flutter_bloc
    → Flutter widgets to build, provide, and manage
    bloc dependencies in the widget tree
    → There's a version for AngularDart
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  32. class CounterBloc extends Bloc {
    @override
    int get initialState => 0;
    @override
    Stream mapEventToState(CounterEvent event) async* {
    switch (event) {
    case CounterEvent.decrement:
    yield currentState - 1;
    break;
    case CounterEvent.increment:
    yield currentState + 1;
    break;
    }
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  33. class CounterBloc extends Bloc {
    @override
    int get initialState => 0;
    @override
    Stream mapEventToState(CounterEvent event) async* {
    switch (event) {
    case CounterEvent.decrement:
    yield currentState - 1;
    break;
    case CounterEvent.increment:
    yield currentState + 1;
    break;
    }
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  34. class CounterBloc extends Bloc {
    @override
    int get initialState => 0;
    @override
    Stream mapEventToState(CounterEvent event) async* {
    switch (event) {
    case CounterEvent.decrement:
    yield currentState - 1;
    break;
    case CounterEvent.increment:
    yield currentState + 1;
    break;
    }
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  35. class CounterBloc extends Bloc {
    @override
    int get initialState => 0;
    @override
    Stream mapEventToState(CounterEvent event) async* {
    switch (event) {
    case CounterEvent.decrement:
    yield currentState - 1;
    break;
    case CounterEvent.increment:
    yield currentState + 1;
    break;
    }
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  36. Flutter
    class CounterApp extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    return MaterialApp(
    title: 'Flutter Demo',
    home: BlocProvider(
    builder: (context) => CounterBloc(),
    child: CounterPage(),
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  37. Flutter
    class CounterApp extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    return MaterialApp(
    title: 'Flutter Demo',
    home: BlocProvider(
    builder: (context) => CounterBloc(),
    child: CounterPage(),
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  38. class CounterPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    final CounterBloc counterBloc = BlocProvider.of(context);
    return Scaffold(
    appBar: AppBar(title: Text('Counter')),
    body: BlocBuilder(
    builder: (context, count) {
    return Text('$count');
    },
    ),
    floatingActionButton: Column(
    children: [
    FloatingActionButton(
    child: Icon(Icons.add),
    onPressed: () { counterBloc.dispatch(CounterEvent.increment); },
    )
    FloatingActionButton(
    child: Icon(Icons.remove),
    onPressed: () { counterBloc.dispatch(CounterEvent.decrement); },
    ),
    ],
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  39. class CounterPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    final CounterBloc counterBloc = BlocProvider.of(context);
    return Scaffold(
    appBar: AppBar(title: Text('Counter')),
    body: BlocBuilder(
    builder: (context, count) {
    return Text('$count');
    },
    ),
    floatingActionButton: Column(
    children: [
    FloatingActionButton(
    child: Icon(Icons.add),
    onPressed: () { counterBloc.dispatch(CounterEvent.increment); },
    )
    FloatingActionButton(
    child: Icon(Icons.remove),
    onPressed: () { counterBloc.dispatch(CounterEvent.decrement); },
    ),
    ],
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  40. class CounterPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    final CounterBloc counterBloc = BlocProvider.of(context);
    return Scaffold(
    appBar: AppBar(title: Text('Counter')),
    body: BlocBuilder(
    builder: (context, count) {
    return Text('$count');
    },
    ),
    floatingActionButton: Column(
    children: [
    FloatingActionButton(
    child: Icon(Icons.add),
    onPressed: () { counterBloc.dispatch(CounterEvent.increment); },
    )
    FloatingActionButton(
    child: Icon(Icons.remove),
    onPressed: () { counterBloc.dispatch(CounterEvent.decrement); },
    ),
    ],
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  41. class CounterPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    final CounterBloc counterBloc = BlocProvider.of(context);
    return Scaffold(
    appBar: AppBar(title: Text('Counter')),
    body: BlocBuilder(
    builder: (context, count) {
    return Text('$count');
    },
    ),
    floatingActionButton: Column(
    children: [
    FloatingActionButton(
    child: Icon(Icons.add),
    onPressed: () { counterBloc.dispatch(CounterEvent.increment); },
    )
    FloatingActionButton(
    child: Icon(Icons.remove),
    onPressed: () { counterBloc.dispatch(CounterEvent.decrement); },
    ),
    ],
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  42. class CounterPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    final CounterBloc counterBloc = BlocProvider.of(context);
    return Scaffold(
    appBar: AppBar(title: Text('Counter')),
    body: BlocBuilder(
    builder: (context, count) {
    return Text('$count');
    },
    ),
    floatingActionButton: Column(
    children: [
    FloatingActionButton(
    child: Icon(Icons.add),
    onPressed: () { counterBloc.dispatch(CounterEvent.increment); },
    )
    FloatingActionButton(
    child: Icon(Icons.remove),
    onPressed: () { counterBloc.dispatch(CounterEvent.decrement); },
    ),
    ],
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  43. class CounterPage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    final CounterBloc counterBloc = BlocProvider.of(context);
    return Scaffold(
    appBar: AppBar(title: Text('Counter')),
    body: BlocBuilder(
    builder: (context, count) {
    return Text('$count');
    },
    ),
    floatingActionButton: Column(
    children: [
    FloatingActionButton(
    child: Icon(Icons.add),
    onPressed: () { counterBloc.dispatch(CounterEvent.increment); },
    )
    FloatingActionButton(
    child: Icon(Icons.remove),
    onPressed: () { counterBloc.dispatch(CounterEvent.decrement); },
    ),
    ],
    ),
    );
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  44. Easy, right?
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  45. Analytics and remote
    debugging, 100% free
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  46. lumberdash
    Do you need logs? Lumberdash is
    the answer!
    void main() {
    putLumberdashToWork(withClient: SimpleClient());
    logWarning('Hello Warning');
    logFatal('Hello Fatal!');
    logMessage('Hello Message!');
    logError(Exception('Hello Error'));
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  47. lumberdash
    Do you need logs? Lumberdash is
    the answer!
    void main() {
    putLumberdashToWork(withClient: SimpleClient());
    logWarning('Hello Warning');
    logFatal('Hello Fatal!');
    logMessage('Hello Message!');
    logError(Exception('Hello Error'));
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  48. lumberdash
    Do you need logs? Lumberdash is
    the answer!
    void main() {
    putLumberdashToWork(withClient: SimpleClient());
    logWarning('Hello Warning');
    logFatal('Hello Fatal!');
    logMessage('Hello Message!');
    logError(Exception('Hello Error'));
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  49. Open Source Lumberdash clients
    → SimpleClient
    → ColorizeClient
    → SentryClient
    → FirebaseClient
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  50. if (isDebug) {
    putLumberdashToWork(withClient: SimpleClient());
    } else {
    putLumberdashToWork(withClient: FirebaseClient());
    }
    logMessage('Hello Message!');
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  51. flutter_bloc

    lumberdash
    class CounterBlocDelegate extends BlocDelegate {
    final LumberdashClient lumberdashClient;
    CounterBlocDelegate(this.lumberdashClient);
    @override
    void onTransition(Transition transition) {
    super.onTransition(transition);
    lumberdashClient.logMessage(transition.toString());
    }
    @override
    void onError(Object error, StackTrace stacktrace) {
    super.onError(error, stacktrace);
    lumberdashClient.logError(error, stacktrace);
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  52. flutter_bloc

    lumberdash
    class CounterBlocDelegate extends BlocDelegate {
    final LumberdashClient lumberdashClient;
    CounterBlocDelegate(this.lumberdashClient);
    @override
    void onTransition(Transition transition) {
    super.onTransition(transition);
    lumberdashClient.logMessage(transition.toString());
    }
    @override
    void onError(Object error, StackTrace stacktrace) {
    super.onError(error, stacktrace);
    lumberdashClient.logError(error, stacktrace);
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  53. flutter_bloc

    lumberdash
    class CounterBlocDelegate extends BlocDelegate {
    final LumberdashClient lumberdashClient;
    CounterBlocDelegate(this.lumberdashClient);
    @override
    void onTransition(Transition transition) {
    super.onTransition(transition);
    lumberdashClient.logMessage(transition.toString());
    }
    @override
    void onError(Object error, StackTrace stacktrace) {
    super.onError(error, stacktrace);
    lumberdashClient.logError(error, stacktrace);
    }
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  54. void main() {
    BlocSupervisor.delegate = CounterBlocDelegate(
    lumberdashClient: lumberdashClient,
    );
    runApp(CounterApp());
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  55. Feature development is
    With these two simple tools, our feature/market
    teams can develop features with a consistent
    pattern, that is well documented, 100% testable,
    and provides automation for logs and analytics.
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  56. @jcocaramos - Droidcon NYC 2019

    View full-size slide

  57. What do we do in the core
    team to ensure quality?
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  58. Tests, automation, and pipelines
    → Unit and widget tests with 100% coverage
    → Integration tests to ensure 60fps at all times
    → Visual dependency graph with validation
    → Consistent lint and code formatting in every
    module
    → Project generators with Docker and pipelines
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  59. Pipeline
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  60. Ozzie
    With Ozzie, during integration tests, we can capture
    screenshots of every frame we want, and we can
    also measure the performance of a feature, breaking
    the build if necessary
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  61. Ozzie
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  62. void main() {
    FlutterDriver driver;
    Ozzie ozzie;
    setUpAll(() async {
    driver = await FlutterDriver.connect();
    ozzie = Ozzie.initWith(driver, groupName: 'counter');
    });
    tearDownAll(() async {
    if (driver != null) driver.close();
    ozzie.generateHtmlReport();
    });
    test('initial counter is 0', () async {
    await ozzie.profilePerformance('counter0', () async {
    await driver.waitFor(find.text('0'));
    await ozzie.takeScreenshot('initial_counter_is_0');
    await driver.tap(find.byType('FloatingActionButton'));
    await driver.waitFor(find.text('1'));
    await ozzie.takeScreenshot('counter_is_1');
    });
    });
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  63. void main() {
    FlutterDriver driver;
    Ozzie ozzie;
    setUpAll(() async {
    driver = await FlutterDriver.connect();
    ozzie = Ozzie.initWith(driver, groupName: 'counter');
    });
    tearDownAll(() async {
    if (driver != null) driver.close();
    ozzie.generateHtmlReport();
    });
    test('initial counter is 0', () async {
    await ozzie.profilePerformance('counter0', () async {
    await driver.waitFor(find.text('0'));
    await ozzie.takeScreenshot('initial_counter_is_0');
    await driver.tap(find.byType('FloatingActionButton'));
    await driver.waitFor(find.text('1'));
    await ozzie.takeScreenshot('counter_is_1');
    });
    });
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  64. void main() {
    FlutterDriver driver;
    Ozzie ozzie;
    setUpAll(() async {
    driver = await FlutterDriver.connect();
    ozzie = Ozzie.initWith(driver, groupName: 'counter');
    });
    tearDownAll(() async {
    if (driver != null) driver.close();
    ozzie.generateHtmlReport();
    });
    test('initial counter is 0', () async {
    await ozzie.profilePerformance('counter0', () async {
    await driver.waitFor(find.text('0'));
    await ozzie.takeScreenshot('initial_counter_is_0');
    await driver.tap(find.byType('FloatingActionButton'));
    await driver.waitFor(find.text('1'));
    await ozzie.takeScreenshot('counter_is_1');
    });
    });
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  65. void main() {
    FlutterDriver driver;
    Ozzie ozzie;
    setUpAll(() async {
    driver = await FlutterDriver.connect();
    ozzie = Ozzie.initWith(driver, groupName: 'counter');
    });
    tearDownAll(() async {
    if (driver != null) driver.close();
    ozzie.generateHtmlReport();
    });
    test('initial counter is 0', () async {
    await ozzie.profilePerformance('counter0', () async {
    await driver.waitFor(find.text('0'));
    await ozzie.takeScreenshot('initial_counter_is_0');
    await driver.tap(find.byType('FloatingActionButton'));
    await driver.waitFor(find.text('1'));
    await ozzie.takeScreenshot('counter_is_1');
    });
    });
    }
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  66. ozzie.yaml
    integration_test_expectations:
    should_fail_build_on_warning: true
    should_fail_build_on_error: true
    performance_metrics:
    missed_frames_threshold:
    warning_percentage: 5.0
    error_percentage: 10.0
    frame_build_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    frame_rasterizer_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  67. ozzie.yaml
    integration_test_expectations:
    should_fail_build_on_warning: true
    should_fail_build_on_error: true
    performance_metrics:
    missed_frames_threshold:
    warning_percentage: 5.0
    error_percentage: 10.0
    frame_build_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    frame_rasterizer_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  68. ozzie.yaml
    integration_test_expectations:
    should_fail_build_on_warning: true
    should_fail_build_on_error: true
    performance_metrics:
    missed_frames_threshold:
    warning_percentage: 5.0
    error_percentage: 10.0
    frame_build_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    frame_rasterizer_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  69. ozzie.yaml
    integration_test_expectations:
    should_fail_build_on_warning: true
    should_fail_build_on_error: true
    performance_metrics:
    missed_frames_threshold:
    warning_percentage: 5.0
    error_percentage: 10.0
    frame_build_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    frame_rasterizer_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  70. ozzie.yaml
    integration_test_expectations:
    should_fail_build_on_warning: true
    should_fail_build_on_error: true
    performance_metrics:
    missed_frames_threshold:
    warning_percentage: 5.0
    error_percentage: 10.0
    frame_build_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    frame_rasterizer_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  71. ozzie.yaml
    integration_test_expectations:
    should_fail_build_on_warning: true
    should_fail_build_on_error: true
    performance_metrics:
    missed_frames_threshold:
    warning_percentage: 5.0
    error_percentage: 10.0
    frame_build_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    frame_rasterizer_rate_threshold:
    warning_time_in_millis: 14.0
    error_time_in_millis: 16.0
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  72. @jcocaramos - Droidcon NYC 2019

    View full-size slide

  73. Recap
    → Complex problems require simple solutions
    → Compliment business goals with engineering
    goals
    → If you don't test it, you don't ship it
    → Automate all the things!
    → Developer experience is key to success
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  74. Questions, comments, feedback... ?
    → @jcocaramos & @ChicagoFlutter
    → Dart 2 In Action
    → jorgecoca.dev ( )
    → https://speakerdeck.com/jorgecoca/flutter-at-
    scale
    @jcocaramos - Droidcon NYC 2019

    View full-size slide

  75. @jcocaramos - Droidcon NYC 2019

    View full-size slide

  76. Happy coding!
    !"
    @jcocaramos - Droidcon NYC 2019

    View full-size slide