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

About Memory Management in Fully Reactive Apps

pakoito
July 06, 2017

About Memory Management in Fully Reactive Apps

This talk covers some of the memory management challenges when working with reactive apps. What are leaks, how they are created when working with Observable chains, and provide advice on how to avoid them. This version also adds comparisons to Architecture Components!

Fully Reactive Apps: https://speakerdeck.com/pakoito/fully-reactive-apps

pakoito

July 06, 2017
Tweet

More Decks by pakoito

Other Decks in Programming

Transcript

  1. @pacoworks
    About Memory Management
    in Fully Reactive Apps
    Paco Estevez - 2017
    1

    View Slide

  2. 2

    View Slide

  3. 3

    View Slide

  4. 4

    View Slide

  5. 5

    View Slide

  6. @pacoworks
    A quick recap of JVM memory management
    Equivalence between classes and lambdas
    Identifying leaks in reactive apps
    Designing solutions that respect memory
    <—— Quiz here!
    6

    View Slide

  7. @pacoworks
    How memory is
    handled by the
    JVM
    7

    View Slide

  8. @pacoworks
    TLDR: Mark & Sweep
    Objects have relations to each other: they have
    explicit fields, or they’re implicitly captured and
    referenced from an outer scope
    When an object doesn’t have any pointer to it it’s
    considered to be ready for collection
    Garbage Collection algorithm marks objects ready for
    collection and then sweeps them from memory
    It’s not deterministic but it’s reliable
    8

    View Slide

  9. @pacoworks
    GC Roots - the unsweepables
    Some objects are marked as Garbage Collection Roots, an
    indication that they will not be considered for collection
    All objects referenced by roots transitively won’t be
    collected either
    Most common roots:
    Locals and parameters in stack frames
    Static fields
    Threads spawned by the app
    9

    View Slide

  10. @pacoworks
    Leaks in Android
    Activities are god objects that contain large chunks of memory
    related to UI and services
    Activities cannot be constructed and have to be manually released
    Activities act as a glorified callback for:
    Activity manager
    Permissions
    Intent requests to other parts of the system
    They are also a service locator and give access to IO
    10

    View Slide

  11. @pacoworks
    “Most leaks in Android are caused by inappropriately
    retaining a callback called Activity.”
    – Maybe Paco
    11

    View Slide

  12. @pacoworks
    Common leak causes -
    Hidden dependency graphs
    class MyApp extends Application {
    DBService dbService;
    }
    class MyActivity extends Activity
    implements DBListener {
    @Inject DBService dbService;
    public void onCreate() {
    dbService.watchChanges("users", this);
    }
    @Override
    public void onChange(Object user) { ... }
    }
    Application keeps
    DBService alive
    Activity is set as a
    DBService callback
    which is never
    disconnected
    Transitive ownership
    assures that Activity
    doesn’t get collected
    12

    View Slide

  13. @pacoworks
    Common leak causes -
    Anonymous class captures
    class MyActivity {
    public void onCreate() {
    NetworkService.getInstance()
    .request(user ->
    updateProfile(user));
    )
    }
    void updateProfile(User user) { ... }
    }
    updateProfile is a
    method of the MyActivity
    class
    The compiler adds an
    implicit reference to
    the MyActivity.this
    object in the lambda
    NetworkService holds the
    callback inside a
    Singleton static
    13

    View Slide

  14. @pacoworks
    Common leak causes -
    Inner classes
    class MyActivity {
    public void onCreate() {
    new Thread(new LongOp()).start();
    }
    void showDetails(Result result) { ... }
    private class LongOp extends Runnable {
    void run() {
    Result result = longOp();
    showDetails(result);
    }
    }
    }
    The compiler adds an
    implicit reference to
    MyActivity.this to
    access showDetails
    RecyclerView holds the
    Adapter, which holds
    the Activity, which
    holds the
    RecyclerView…
    Leaks in a Thread
    14

    View Slide

  15. @pacoworks
    Fixing leaks in OOP
    class MyActivity implements Callback {
    private LongOp op;
    public void onCreate() {
    op = new LongOp(this);
    new Thread(op).start();
    }
    public void onDestroy() {
    if (op != null) {
    op.release();
    }
    }
    void call(Data data) {
    showDetails(data);
    }
    private showDetails(...) { ... }
    }
    class LongOp extends Runnable {
    private Callback callback;
    class LongOp(Callback callback) {
    this.callback = callback;
    }
    void release() {
    callback = null;
    }
    void run() {
    if (callback != null) {
    callback.call(longOp());
    }
    }
    interface Callback {
    void call(Data data);
    }
    }
    15

    View Slide

  16. @pacoworks
    Fixing leaks in OOP
    Mutable fields or box references i.e. WeakReference
    Manual management of attach-detach
    null checks
    Not thread safe without effort
    No real business value
    16

    View Slide

  17. @pacoworks
    A touch of
    functional
    programming
    17

    View Slide

  18. @pacoworks
    Solving transitive ownership:
    where to store dependencies?
    18

    View Slide

  19. @pacoworks
    Open class…
    class RequestUseCase {
    private final INetworkService network;
    private final IDataBase db;
    public RequestUseCase(INetworkService network,
    IDataBase db) {
    this.network = network;
    this.db = db;
    }
    Observable operation(String id) {
    return Observable.just(id)
    .flatMap(id ->
    network.request(id))
    .flatMap(result ->
    db.store(result));
    }
    }
    All business logic
    Retained fields that
    are only transitive
    dependencies
    19

    View Slide

  20. @pacoworks
    …or closures?
    static Observable operation(
    String id,
    final INetworkService network,
    final IDataBase db) {
    return Observable.just(id)
    .flatMap(id ->
    network.request(id))
    .flatMap(result ->
    db.store(result));
    }
    Lambdas capture the
    dependencies
    Doesn’t require to
    write an entire
    class
    Dependencies are
    retained only for
    as long as the
    Observable is alive
    20

    View Slide

  21. @pacoworks
    Open class or closures?
    class MyActivity {
    @Inject INetworkService network;
    @Inject IDataBase db;
    private MyUseCase useCase;
    public void onCreate() {
    /* Creates a transitive ownership node */
    useCase = new MyUseCase(network, db);
    }
    public void onResume() {
    useCase.operation("1")
    .subscribe(result ->
    displayResult(result));
    }
    }
    class MyActivity {
    @Inject INetworkService network;
    @Inject IDataBase db;
    public void onResume() {
    /* No new ownerships */
    operation("1", network, db)
    .subscribe(result ->
    displayResult(result));
    }
    }
    21

    View Slide

  22. @pacoworks
    Properties of closures
    Fancy name for capturing local dependencies in lambdas &
    single-method anonymous classes
    Can be used as a poor man’s class on the spot
    Values are captured as implicit final fields
    Retained values are encapsulated privately
    Reusable through functional patterns
    22

    View Slide

  23. @pacoworks
    Reusing functions -
    Partial Application
    Function> applied =
    apply((network, db, id) ->
    operation(network, db, id),
    /* These values get pre-filled */
    network, db);
    Observable.merge(
    applied.call("1"),
    applied.call("2"),
    applied.call("fancy")
    ).subscribe(result -> show(result));
    Creates a partially
    applied function
    object during runtime
    Captures some
    dependencies inside a
    closure that calls the
    method passed
    Like RequestUseCase
    but as a reusable
    pattern
    23

    View Slide

  24. @pacoworks
    But…
    …classes are supposed to encapsulate state and behaviour
    By externalising the state and services to be injected
    for testing purposes you have already broken this
    principle
    …what if my class encapsulates multiple behaviours for
    the same fields?
    The fields are the state, your methods are your
    operations. If you extract the state and pass it to
    each function explicitly you should get the same result
    24

    View Slide

  25. @pacoworks
    Static singletons are passé
    25

    View Slide

  26. @pacoworks
    Perfect functional world
    Dependencies should be injected as instances of a
    function, plain data, or an Observable
    State is external and separate to behaviour,
    services are pure functions
    Scope dependencies and avoid Singletons
    simple fields in Activity and Application
    with a module like in Dagger
    26

    View Slide

  27. @pacoworks
    Injecting static methods
    /* Capture statics */
    Observable operation() {
    NetworkService.request()
    .doOnError(e -> RxLogger.logError(e));
    }
    /* Pass globals as functions */
    Observable operation(
    Callable> network,
    Consumer logger) {
    return network.call()
    .doOnError(logger);
    }
    ...
    /* Call it with a function object */
    operation(
    () -> NetworkService.request(),
    e -> RxLogger.logError(e));
    Solves dependencies on
    static methods
    Decouples so no testing
    libraries like PowerMock
    are required
    Doesn’t solve any memory
    management issues
    Interfaces may be different in RxJava1 and Kotlin
    27

    View Slide

  28. @pacoworks
    Wrapping Singletons
    static Observable wrap(
    ConnectivityService service) {
    return Observable.create(emitter -> {
    final Callback callback =
    /* Forward value from callback */
    isConnected -> emitter.onNext(isConnected);
    /* Remove callback when the Observable terminates */
    emitter.setCancellable(() -> service.removeCb(callback));
    /* Start listening */
    service.addCb(callback);
    });
    }
    operation(() ->
    wrap(ConnectivityService.getInstance(activity))
    );
    Wraps the Singleton in a
    closure
    Matches the lifecycle of
    an Observable
    Fixes memory management
    problems, but it also
    hides them
    Decouples from
    ConnectivityService to
    test without using
    libraries like PowerMock
    28

    View Slide

  29. @pacoworks
    Threading
    challenges
    using RxJava
    29

    View Slide

  30. @pacoworks
    Those pesky Threads
    Transitive dependencies and Statics are mostly
    design problems that can be fixed before they touch
    application logic
    Threads are a runtime behaviour problem that is
    framework and paradigm agnostic in the JVM
    Our UI framework is properly single-threaded
    Observable as our abstraction for concurrency
    30

    View Slide

  31. @pacoworks
    Dissecting Observable creation
    An Observable receives an
    Observer, which contains a
    function to execute upon
    subscription
    Each operation like map()
    or scan() creates its own
    nested Observer
    Operations like
    subscribeOn() schedule
    thread changes inside
    closures
    parent.setDisposable(
    scheduler.scheduleDirect(
    () ->
    source
    .subscribe(parent)
    ));
    ———>
    ———>
    31

    View Slide

  32. @pacoworks
    Dissecting Observable unsubscription
    Sequentially destroys all
    nested Observers in the chain
    Dispose is called in each one
    of them to set them to null
    Garbage Collection gets rid
    of them eventually
    Scheduled operations are
    still running but their
    results are not sent forward
    /**
    * Atomically disposes the Disposable in the field
    if not already disposed.
    * @param field the target field
    * @return true if the current thread managed to
    dispose the Disposable
    */
    public static boolean
    dispose(AtomicReference field) {
    Disposable current = field.get();
    Disposable d = DISPOSED;
    if (current != d) {
    current = field.getAndSet(d);
    if (current != d) {
    if (current != null) {
    current.dispose();
    }
    return true;
    }
    }
    return false;
    }
    32

    View Slide

  33. @pacoworks
    The Observable memory
    lifecycle
    Gets created with a bunch of functions nested in
    Observers
    Runs synchronously or asynchronously depending on
    the operations defined by the Observers
    Upon completion or cancellation it nulls all the
    nested references inside, including threaded ones
    33

    View Slide

  34. @pacoworks
    Quiz for the audience!
    What keeps these Observable subscriptions alive?
    34

    View Slide

  35. @pacoworks
    Quiz 1
    public void onCreate() {
    Observable.just(1)
    .subscribe(num -> show(num));
    }
    35

    View Slide

  36. @pacoworks
    Quiz 1
    public void onCreate() {
    Observable.just(1)
    .subscribe(num -> show(num));
    }
    onCreate() stack frame
    36

    View Slide

  37. @pacoworks
    Quiz 2
    public void onCreate() {
    RxView.clicks(label).subscribe(...);
    }
    37

    View Slide

  38. @pacoworks
    Quiz 2
    public void onCreate() {
    RxView.clicks(label).subscribe(...);
    }
    label.onClickListener()
    38

    View Slide

  39. @pacoworks
    Quiz 3
    public class StateHolder {
    PublishSubject myStrings =
    PublishSubject.create();
    }
    39

    View Slide

  40. @pacoworks
    Quiz 3
    public class StateHolder {
    PublishSubject myStrings =
    PublishSubject.create();
    }
    whatever retains StateHolder
    40

    View Slide

  41. @pacoworks
    Quiz 4
    public void onCreate() {
    Observable.interval(
    1, TimeUnit.SECOND,
    Schedulers.computation())
    .subscribe(num -> show(num));
    }
    41

    View Slide

  42. @pacoworks
    Quiz 4
    public void onCreate() {
    Observable.interval(
    1, TimeUnit.SECOND,
    Schedulers.computation())
    .subscribe(num -> show(num));
    } thread in computation
    42

    View Slide

  43. @pacoworks
    Quiz 4
    public void onCreate() {
    Observable.interval(
    1, TimeUnit.SECOND,
    Schedulers.computation())
    .subscribe(num -> show(num));
    } thread in computation
    LOOKS LIKE A POTENTIAL LEAK TO ME
    43

    View Slide

  44. @pacoworks
    Terminating Observables
    “Natural causes”
    Retain a Subscription and cancel it manually
    Using one of the operators like takeUntil()
    44

    View Slide

  45. @pacoworks
    Cancelling a Subscriber
    Simple solution, not far from the OOP way
    For most cases it plays nice with the Activity
    lifecycle
    You have twenty lovely MV* libraries with variations
    of same idea
    45

    View Slide

  46. @pacoworks
    Cancelling a Subscriber
    Simple solution, not far from the OOP way
    For most cases it plays nice with the Activity
    lifecycle
    You have twenty twenty-one lovely MV* libraries with
    variations of same idea
    46

    View Slide

  47. @pacoworks
    The operator takeUntil()
    Allows for fine-grained lifecycle control
    Needs an Observable signal, and cancels the
    Observable once the first value is received
    Works with the Android lifecycle with some
    adjustments
    47

    View Slide

  48. @pacoworks
    Reactive Activity lifecycle
    BehaviorRelay lifecycle =
    BehaviorRelay.create()
    protected void onCreate(Bundle b) {
    if (b == null) {
    lifecycle.call(ENTER);
    }
    lifecycle.call(CREATE);
    }
    protected void onDestroy() {
    lifecycle.call(DESTROY);
    if (isFinishing()) {
    lifecycle.call(EXIT);
    }
    }
    Map every
    lifecycle state to
    a piece of data
    Store the data in
    an Observable that
    caches the latest
    value, like
    BehaviourSubject
    or BehaviourRelay
    48

    View Slide

  49. @pacoworks
    Architecture Components
    Activity lifecycle
    Activities and fragments implement LifecycleOwner
    A LifecycleOwner provides a Lifecycle. It behaves like an
    Observable-lite class for our subscriptions
    49

    View Slide

  50. @pacoworks
    Fixing threads with
    takeUntil()
    public void onCreate() {
    lifecycle
    .filter(is(CREATE))
    .switchMap(enter ->
    Observable.interval(
    1, TimeUnit.SECOND,
    computation()))
    .observeOn(mainThread())
    .takeUntil(lifecycle.filter(is(DESTROY)))
    .subscribe(num ->show(num))
    }
    Start operation only
    if state is CREATE
    Cancel all previous
    intervals with
    switchMap()
    Stop the interval
    and listening to the
    lifecycle if state
    is DESTROY
    50

    View Slide

  51. @pacoworks
    Fixing Singletons with
    takeUntil()
    operation(() ->
    wrap(ConnectivityService.getInstance(activity))
    .takeUntil(lifecycle.filter(is(DESTROY)))
    );
    Ties the lifecycle of
    the Observable to the
    Activity
    Only happens on the
    Android layer
    Transparent for the
    Singleton and the
    operation
    Allows chains where only
    some Observables are
    tied to Activities
    51

    View Slide

  52. @pacoworks
    Designing
    Fully Reactive
    Apps
    52

    View Slide

  53. @pacoworks
    High level overview
    A Business Logic layer where data is transformed
    according to an specification
    A View layer as a way to interact with the user
    A Services layer to interact with the system
    53

    View Slide

  54. @pacoworks
    High level overview
    A Business Logic layer where data is transformed
    according to an specification
    A View layer as a way to interact with the user
    A Services layer to interact with the system
    JUST FOCUSING ON MEMORY TODAY
    54

    View Slide

  55. @pacoworks
    The passive View
    Reactive world mandates a purely passive view
    A View can have two operations: output and input
    55

    View Slide

  56. @pacoworks
    Not the passive View
    Reactive world mandates a purely passive view
    A View can have two operations: output and input
    56

    View Slide

  57. @pacoworks
    Some passive Views
    // DragAndDropView
    void updateElements(List elements);
    void updateSelected(Set selected);
    // RotationViewInput
    void showLoading(String message);
    void showError(String reason);
    void showWaiting(int seconds);
    void showRepositories(List repos);
    Simple data
    formatted to be
    displayed on
    screen
    “I don’t care how
    you display it”
    Note these are not part of an interface
    57

    View Slide

  58. @pacoworks
    The best passive View
    Reactive world mandates a purely passive view
    A View has one operation: display data
    Any reference to it is a potential cause of leaks,
    as it will always come with a reference to any UI
    widgets captured
    58

    View Slide

  59. @pacoworks
    The View-Service duality
    A View layer as a way to interact with the user
    A Services layer to interact with the system
    59

    View Slide

  60. @pacoworks
    The View-Service duality
    A View layer as a way to display information
    A Services layer to interact with the user and the
    system
    60

    View Slide

  61. @pacoworks
    Some Services
    // DragAndDropUserInteraction
    Observable> dragAndDropMoves();
    Observable> listClicks();
    // DatabaseService
    Single> queryEntriesBefore(Date date);
    // ActivityScreen
    Observable lifecycle();
    // NetworkService
    Single request(String id);
    // StateHolder
    Observable updates();
    A service is a
    collection of functions
    returning a version of
    Observable
    Decoupled from space,
    time, and error handling
    Data received is
    immutable and never null
    Signatures do not know
    of Android framework
    code
    Note these are not part of an interface
    61

    View Slide

  62. @pacoworks
    Problems with Services
    They are bound to the part of the code they talk to:
    Hold a Context for IO
    Bound to the Activity lifecycle
    Hold a reference to a widget
    Opinionated 3rd party libraries
    62

    View Slide

  63. @pacoworks
    Problems with Services
    They are bound to the part of the code they talk to:
    Hold a Context for IO
    Bound to the Activity lifecycle
    Hold a reference to a widget
    Opinionated 3rd party libraries <—— Wrap or replace
    63

    View Slide

  64. @pacoworks
    Fixing Activity problems
    in Services
    wrap(ConnectivityService.getInstance(activity))
    .takeUntil(lifecycle.filter(is(DESTROY)))
    Hold a Context for
    IO
    Bound to the
    Activity lifecycle
    64

    View Slide

  65. @pacoworks
    Fixing Activity problems
    in Services
    wrap(ConnectivityService.getInstance(activity))
    .takeUntil(lifecycle.filter(is(DESTROY)))
    Hold a Context for
    IO
    Bound to the
    Activity lifecycle
    65

    View Slide

  66. @pacoworks
    Fixing Activity problems
    in Architecture Components
    66
    Hold a Context for
    IO
    Bound to the
    Activity lifecycle
    <———

    View Slide

  67. @pacoworks
    Fixing widget problems in
    view Services
    @Bind(R.id.my_bview)
    View label;
    Observable viewClicks = null;
    void onCreate() {
    viewClicks = RxView.clicks(label);
    }
    @Override
    Observable viewClicks() {
    // Leak! The Observable retains
    // a direct reference to the label
    return viewClicks;
    }
    VIEW
    RXVIEW
    BUSINESS LOGIC
    67

    View Slide

  68. @pacoworks
    Fixing widget problems in
    view Services
    PublishRelay clicksRelay =
    PublishRelay.create();
    void onCreate() {
    // Not a leak! The Observable lives in
    // the view’s onClickListener and
    // is collected alongside the view
    RxView.clicks(label)
    .subscribe(clicksRelay);
    }
    Observable viewClicks() {
    // Only the Relay is passed along
    return clicksRelay.asObservable();
    }
    VIEW RXVIEW
    BUSINESS LOGIC PUBLISH
    68

    View Slide

  69. @pacoworks
    Real problems with Services
    Identify which ones are leaky and which ones aren’t
    Leaky can only run while the Activity is alive
    Not leaky can run until required or completion
    69

    View Slide

  70. @pacoworks
    All together now!
    70

    View Slide

  71. @pacoworks
    Our business logic function
    static void subscribePagination(
    PaginationState state,
    Observable endOfPage,
    Function>> service) {
    // Chain with switchMap
    doSM(
    // For every new state
    () -> state.elements,
    // Wait for the end of the page
    elements -> endOfPage.first(),
    // Get latest page
    elements, click -> state.pages.first(),
    // Request and append new page
    elements, click, page ->
    service.apply(page)
    .map { elements.plus(it) }
    .doOnNext { state.pages.call(page + 1) }
    ) // Reapply state
    .subscribe(state.elements)
    }
    Coupled only with
    functions,
    observables and data
    Single
    responsibility
    No leaky direct
    interaction with UI
    No UI thread
    required
    71

    View Slide

  72. @pacoworks
    Subscribing our business
    logic
    class PaginationScreen {
    PaginationState state = new PaginationState();
    PublishRelay endOfPagePRelay = PublishRelay.create();
    @Inject NetworkService network;
    public PaginationScreen() {
    /* Run only once per creation */
    subscribePagination(
    state,
    endOfPagePRelay,
    page ->
    network.requestMore(page));
    }
    public void onCreate() {
    /* Android code to forward end of page to relay */
    }
    }
    Apply values to
    state rather than
    directly to UI
    Relay to proxy and
    avoid passing the
    view
    NetworkService is
    passed as a
    function
    72

    View Slide

  73. @pacoworks
    Abstracting binding State
    to UI
    void bind(Observable lifecycle,
    Scheduler mainThreadScheduler,
    Observable state,
    Consumer viewAction) {
    lifecycle
    .filter(is(CREATE))
    .switchMap(state)
    .observeOn(mainThreadScheduler)
    .takeUntil(
    lifecycle
    .filter(is(DESTROY)))
    .subscribe(viewAction)
    }
    Fixes leaky UI!
    Bind with lifecycle
    assurance
    Add main thread too
    Observable
    subscribed with
    Consumer
    73

    View Slide

  74. @pacoworks
    Binding our UI
    class PaginationScreen {
    BehaviorRelay lifecycle = BehaviorRelay.create();
    PaginationState state = new PaginationState();
    public PaginationScreen() {
    /* Subscription code */
    }
    public void onCreate() {
    /* Android code to initialize UI and forward to relays */
    /* Bind state once the UI is ready */
    bind(
    lifecycle, mainThread(),
    state.elements,
    elements -> updateList(elements));
    }
    void updateList(List elements) { ... }
    }
    Our UI dependency
    is passed as a
    function
    Binding only after
    UI is ready
    74

    View Slide

  75. @pacoworks
    Recap
    75

    View Slide

  76. @pacoworks
    Fully Reactive Apps
    Subscriptions to business logic will run until
    origin stored in PaginationState disappears
    76

    View Slide

  77. @pacoworks
    Quiz 3 - Reminder
    public class PaginationState {
    BehaviorRelay elements =
    BehaviorRelay.create();
    BehaviorRelay pages =
    BehaviorRelay.create();
    }
    screen retains PaginationState
    77

    View Slide

  78. @pacoworks
    Fully Reactive Apps
    Subscriptions to business logic will run until
    origin stored in PaginationState disappears
    That means that they’ll survive across rotation as
    long as the state is retained too!
    78

    View Slide

  79. @pacoworks
    Fully Reactive Apps
    Subscriptions to business logic will run until
    origin stored in PaginationState disappears
    That means that they’ll survive across rotation as
    long as the state is retained too!
    Lifecycle-bound services are transparent
    79

    View Slide

  80. @pacoworks
    Fully Reactive Apps
    Subscriptions to business logic will run until
    origin stored in PaginationState disappears
    That means that they’ll survive across rotation as
    long as the state is retained too!
    Lifecycle-bound services are transparent
    Bind with UI runs between CREATE and DESTROY and
    will be called again onCreate()
    80

    View Slide

  81. @pacoworks
    Fully Reactive Apps:
    http://www.pacoworks.com/
    2016/11/02/fully-reactive-apps-at-
    droidcon-uk-2016-2/
    Modern Garbage Collection:
    https://blog.plan99.net/modern-
    garbage-collection-911ef4f8bd8e
    Sample project:
    https://github.com/pakoito/
    FunctionalAndroidReference
    pacoworks.com
    @pacoworks
    github.com/pakoito
    Slides: http://tinyurl.com/RxMemMadrid17
    81

    View Slide