Synetech

Reactive Countdown

Časový odpočet není funkcionalita, která by se v mobilních aplikacích používala často, existuje ale nemalé množství případů, ve kterých může být přidání odpočtu užitečné, uživatelsky přívětivé, nebo dokonce zásadně důležité. Uživatelům může například zpříjemnit používání eventové aplikace zobrazením času zbývajícího do začátku akce, může se hodit při vytváření vlastního časovače, apod.

Jak se dá ale ve skutečnosti takový odpočet naimplementovat? Je jasné, že odečítání vteřin pomocí NSTimer třídy není tím správným způsobem. Hned další otázka vyvstává v okamžiku, kdy se cílové datum, ke kterému odpočítáváme (například čas události) změní a aplikace je v tu chvíli již vydaná. Jak v tu chvíli zareagujeme? Je jasné, že datum nemůže být napevno zadrátované v aplikaci. Odpovědi na tyhle otázky naleznete v následujícím článku, tak jdeme na to!

Foreword

Než začneme, pojďme se v rychlosti podívat na problém, se kterým se potýkáme.

Několikrát do roka pořádáme událost zvanou AppParade, pro kterou jsme vytvořili mobilní aplikaci, která před začátkem akce ukazuje mimo jiné odpočet do jejího startu. Datum nebo čas události se může kdykoliv změnit, a proto je potřeba napsat odpočet tak, aby byl reaktivní.

Zdrojový kód v následujících sekcích je napsaný pro Rx. Pokud nemáte tušení, co je Rx, podívejte se na následující stránky: ReactiveX or Combine from Apple

The Code

Začneme tím, že se podíváme na to, jak odpočet vypadá.

Reaktivní odpočet

Odpočet závisí na jednom vstupu, kterým je datum, ke kterému odpočítáváme. To je poskytnuto databází (Firebase Firestore) a může být dynamicky měněno. Kdykoliv ke změně dojde, musí se odpočet změnit také.

Snažíme se dosáhnout toho, abychom přečetli zdroj pouze jednou a poté už jenom reagovali na jeho změny. Na základě změn chceme pravidelně aktualizovat UI, čímž docílíme příjemného uživatelského chování.

Abychom předešli tomu, že odpočet občas přeskočí vteřinu (jak je vidět na obrázku), je potřeba aktualizovat UI častěji, než jenom jednou za vteřinu.

Vynechání sekundy

Abychom předešli tomu, že odpočet občas přeskočí vteřinu (jak je vidět na obrázku), je potřeba aktualizovat UI častěji, než jenom jednou za vteřinu.

Nejprve si zadefinujeme model, který bude reprezentovat odpočet s počtem dní, hodin, minut a vteřin.

Ukázky kodu jsou v jazyce Swift.

/// Representation of a timer that can be used to display the countdown
struct Timer {
    let seconds: Int
    let minutes: Int
    let hours: Int
    let days: Int
}

extension Timer {
    /// Create a timer from time interval
    ///
    /// - Parameter interval: time interval in seconds
    /// - Returns: new Timer object
    static func from(interval: TimeInterval) -> Timer {
        let seconds = Int(interval.truncatingRemainder(dividingBy: 60))
        let minutes = Int((interval / 60).truncatingRemainder(dividingBy: 60))
        let hours = Int((interval / 3600).truncatingRemainder(dividingBy: 24))
        let days = Int((interval / (24 * 3600)))
        return Timer(seconds: seconds, minutes: minutes, hours: hours, days: days)
    }

    /// Print time for debuging
    func printTime() {
        print("\(self.days) days, \(self.hours) hours, \(self.minutes) minutes, \(self.seconds) seconds")
    }
}

Dále vytvoříme in-memory úložiště, které poskytne datum, ke kterému odpočítáváme

/// End date as it would be provided by a remote database
let endDate = BehaviorRelay < Date > (value: Date.distantFuture);
/// The observable provided by the "database"
let endDateObsevable = endDate.asObservable();

a vzorkovací frekvenci, se kterou chceme, aby se UI aktualizovalo (nebo v tomto případě vypisovalo výstup)

/// End date as it would be provided by a remote database
let endDate = BehaviorRelay < Date > (value: Date.distantFuture);
/// The observable provided by the "database"
let endDateObsevable = endDate.asObservable();

Nakonec je čas na trochu kouzlení s Rx. Chceme vzít datum, ke kterému odpočítáváme, spojit ho s aktuálním časem a provádět vše opakovaně v intervalu vzorkovací frekvence. Toho se dá docílit více způsoby, zde se podíváme na dva z nich.

  1. Použití flatMap k opakovanému kontrolování endDate v intervalech vzorkovací frekvence a následně použití build-in funkce timeIntervalSinceNow, která vypočítá počet vteřin od této chvíle do daného data.
/// Rx Magic happening here
endDateObsevable
    .flatMapLatest{ (endDate) in
        return sampling.map { _ in
            return endDate
        }
    }
    .map { (endDate) in
        return endDate.timeIntervalSinceNow
    }
    .map { (interval) -> Timer in
        return Timer.from(interval: interval)
    }
    .subscribe(onNext: { (timer) in
        timer.printTime()
    })

Výstup je následující:

K tomu, abychom viděli odpočet pouze k poslední hodnotě endDate, musíme použít flatMapLatest. Tato funkce funguje podobně jako flatMap, akorát mapuje pouze hodnoty z poslední Observable. Bez použití flatMapLatest by se v okamžiku, kdy změníme endDate, časovače promíchaly tak, jak je možné vidět na tomto videu.

  1. Druhý způsob, kterým lze chování docílit, je použití combineLatest operátoru. Tento operátor nám umožní zkombinovat nejnovější hodnoty z poskytnutých Observables. Jednou Observable bude endDate a tou druhou vzorkovací frekvence. Zbytek je stejný jako v prvním případě.
Observable.combineLatest(endDateObsevable, sampling)
{  (endDate, _) in
    return endDate.timeIntervalSinceNow
    }
    .map { (interval) -> Timer in
        return Timer.from(interval: interval)
    }
    .subscribe(onNext: { (timer) in
        timer.printTime()
    })

Abychom na závěr ušetřili výpočetní výkon zařízení, použijeme operátor distinctUntilChanged, který přeskakuje hodnoty, dokud nedojde k jejich změně. UI se poté nemusí aktualizovat s každým ťikem vzorkovací frekvence, ale pouze tehdy, když nastane změna, kterou je potřeba ukázat uživateli.

Nepřeruš Rx řetězec! Hodněkrát jsem viděl vývojáře bojovat s tím, jak dostat do view výsledek Rx řetězce, který je zabalený v Observable, které dokonale nerozuměli. Většinou problém vyřešili tak, že se subscribnuli k Observable někde na úrovni viewModelu/presenteru a uložili ji do proměnné, kterou poté poskytovali view. Toto řešení má řadu nevýhod. Zaprvé, při tomto řešení je nutné aktualizovat view, což je hloupé v případě, kdy pracujeme s rx řetězcem. Zadruhé, kdykoliv dojde k dealokaci view, změny jsou stále sledovány, čímž se zbytečně využívají systémové zdroje. V neposlední řadě může dojít k chybě v případě, kdy se view správně nesynchronizuje.

Reaktivní odpočet je tímto dokončen. Teď už jenom stačí vytvořit vzdálené úložiště, ze kterého se bude brát datum události a všechno je připraveno. Pod tímto článkem jsou k nalezení ukázkové projekty s implementací za použití knihoven RxSwift a Combine. Neváhejte a vyzkoušejte si to sami.

Díky za přečtení a mobilnímu vývoji zdar!


Kompletní kód Swift ukázky zde:

RxCountDown.zip

Také jsem vytvořil ukázku, kde používám Combine místo RxSwift.

CombineCountdown.playground.zip