Mobile and web app development - Appchance - Digital Products Experts

22 września, 2018

Implementacja stref czasowych (time zones) na iOS.

Time Zones

Development aplikacji mobilnych to nieustanne wysyłanie oraz pobieranie danych. Podczas wysyłania bardzo często trzeba załączyć czas zdarzenia, który zależy od lokalizacji. Wtedy pojawia się dylemat - w jaki sposób wysłać poprawnie datę oraz czas na serwer uwzględniając strefę czasową użytkownika?

Winowajcą całego zamieszania jest ruch obrotowy ziemi, który sprawia, że słońce pojawia się w różnych momentach w określonych lokalizacjach. Konsekwencją tego jest różnica czasu w oddalonych od siebie miejscach na Ziemi.

Aby dobrze zrozumieć znaczenie implementacji standardów czasowych w aplikacji, musimy rozpocząć od terminów odnoszących się do stref czasowych i zamieszania, które wywołują.

Najczęstszym odwołaniem przy wyznaczaniu stref czasowych jest UTC (Coordinated Universal Time) czyli standard służący ustalaniu stref czasowych na świecie. Oznacza to, że nie stanowi on oficjalnego czasu dla żadnego z terytoriów (jest taki sam dla wszystkich krajów), natomiast służy do wyznaczania czasu w danych strefach. Strefy czasowe na świecie są wyrażane za pomocą ujemnych bądź dodatnich przesunięć względem UTC. Np. Dubai znajduje się w strefie GST, czyli UTC + 4, co oznacza, że jeżeli jesteś w Dubaju i mamy godzinę 11 rano (GST = UTC + 4), to w według czasu UTC będzie to godzina (UTC = GST - 4) 7 rano.

UTC jest często mylony z GMT (Greenwich Mean Time) z tego względu, że w praktyce ich wartości się pokrywają. GMT (określany w kręgach militarnych jako Zulu) odnosi się jednak do danej lokalizacji wyznaczonej przez południk 0, który przechodzi przez Greenwich, czyli jest czasem odnoszącym się do kilku krajów Europy Wschodniej oraz Afryki.

Pomimo tego że UTC oraz GMT to w praktyce ten sam czas, różnica pomiędzy nimi jest następująca:

  • GMT to strefa czasowa oraz standard opisujący czas, używany do lat siedemdziesiątych
  • UTC to nowy powszechnie stosowany standard, który reguluje czas na całym świecie, podobnie jak GMT punktem wzorcowym jest czas w południku zero. Opisywany jest za pomocą czasu atomowego oraz Astronomical Time, który odnosi się do ruchu obrotowego Ziemi. Jak się okazuje obrót ziemi zwalnia w porównaniu do czasu atomowego, który tyka z tą samą prędkością. Aby zsynchronizować oba czasy należy co jakiś czas dodawać do czasu atomowego tzw. leap second. Jest to główna różnica pomiędzy tymi dwoma czasami.

Aby zrozumieć wpływ stref czasowych na branżę mobilną, posłużmy się przykładem. Wyobraź sobie, że właśnie zapisałeś się na konferencję w Londynie, na którą lecisz z Warszawy. W trakcie przerwy umówiłeś się za pomocą komunikatora w aplikacji eventowej na spotkanie 1:1 z Markiem Zuckerbergiem, a następnie z Larrym Kimem. Niestety aplikacja, której używałeś, nie bierze pod uwagę lokalnych stref czasowych, przez co godziny spotkań na którą się umówiliście będą różne.

Oznacza to, że nigdy nie będziesz miał okazji się spotkać z Markiem Zuckerbergiem i ze spuszczoną głową czeka Cię powrót do domu - zakładając że aplikacja linii lotniczych również działa poprawnie :)

Wściekasz się i przeklinasz twórców aplikacji.

W tym artykule pokażemy co zrobić aby uniknąć przytoczonej sytuacji i nie być wśród przeklętej grupy twórców aplikacji.

Czas w iOS możemy zapisać w postaci:

  • Timestamp np. 1507140070.76334 reprezentujący liczbę sekund od January 1st, 1970 w czasie UTC
  • String, tekst np. 2017-10-04 20:01:10
  • Date - struktura używana do reprezentacji daty wewnątrz aplikacji

Timestamp oraz String to formaty, które stanowią reprezentację czasu. Dzięki nim możemy wysyłać czas na serwer, Android i inne platformy.

W Appchance, czas zapisujemy w postaci String ze względu na jego czytelność (gołym okiem można dostrzec czy dany czas jest poprawny).

Operując na datach oraz godzinach w aplikacjach Swift, korzystamy ze struktury Date, która jest specyficznym punktem w czasie, niezależnym od stref czasowych i nie posiadająca o nich informacji. Struktura ta implementuje przydatne protokoły  Comparable, Equatable, dzięki czemu możemy w łatwy sposób porównywać dwie daty ze sobą.

Warto sprawdzić kod źródłowy struktury Date na GitHubie.

Jak widać, czas zapisywany jest tam w zmiennej

fileprivate var _time : TimeInterval

gdzie TimeInterval to tak naprawdę alias do typu Double

Gdy tworzymy teraźniejszą datę

let now = Date()

wywołujemy konstruktor

public init() {	
      _time = CFAbsoluteTimeGetCurrent()
}

Metoda CFAbsoluteTimeGetCurrent zwraca czas bezwzględny. Gdzie czas bezwzględny jest mierzony w sekundach od daty referencyjnej Styczeń 1 2001 00:00:00 GMT. Struktura Date zawiera więc czas reprezentowany w postaci ilości sekund, które upłynęły od reference date (Jan 1 2001 00:00:00) w czasie UTC.

Aby zatem poprawnie wyświetlać czas w strefie czasowej użytkownika potrzebna jest nam klasa pomocnicza, która przeliczy czas w Date na czas lokalny.

Klasa DateFormatter pozwala nam konwertować daty z postaci tekstowej na obiekty Date i odwrotnie. Dodatkowo DateFormatter pozwala nam definiować customowe formaty oraz w łatwy sposób nakładać strefy czasowe (domyślnie DateFormatter korzysta z lokalnej strefy czasowej użytkownika)

Najczęściej spotykany błąd jaki popełniają programiści polega na wysłaniu czasu lokalnego bez dołączenia informacji o strefie czasowej. Przez co odbiorca nie ma wystarczających informacji aby przeliczyć czas i dostosować go do swojej strefy czasowej. Przesyłany czas działa zatem poprawnie wyłącznie w ramach jednej strefy.

Załóżmy, że chcemy wysłać current date na serwer. Możemy to zrobić w następujący sposób:

func dateToString(fromDate date: Date) -> String {
    let dateFormatter = DateFormatter()
    
    dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
    
    return dateFormatter.string(from: date)
}

Aplikacja zwraca pięknie sformatowaną current date

2017-10-04 20:11:13 (załóżmy że jest to godzina w strefie CEST = UTC + 2)

Użytkownik z innej strefy czasowej pobiera przesłaną na serwer datę za pomocą usług sieciowych.

Wczytujemy ją jako ciąg znaków i konwertujemy na strukturę Date

func stringToDate(fromString string: String) -> Date? {
    let dateFormatter = DateFormatter()

    dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"

    return dateFormatter.date(from: string)
}

Tak stworzoną datę możemy zaprezentować userowi formatując strukturę Date za pomocą DateFormatter

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
 
if let date = stringToDate(fromString: stringDate) {
    print(dateFormatter.string(from: date))
}

Niestety pomimo tego, że użytkownik znajduje się w innej strefie czasowej zostaje wyświetlona mu godzina czasu lokalnego nadawcy, czyli “2017-10-04 20:11:13”.

Aby poprawnie wysłać datę, trzeba ją sprowadzić do czasu UTC i dopisać do niej informacje o zastosowanej strefie czasowej, jak na poniższym przykładzie:

func dateToString(fromDate date: Date) -> String {
    let dateFormmater = DateFormatter()
 
    dateFormmater.timeZone = TimeZone(identifier: "UTC")
    dateFormmater.dateFormat = "yyyy-MM-dd HH:mm:ss'Z'"
    
    return dateFormmater.string(from: date)
}

gdzie ‘Z’ - to symbol reprezentujący strefę GMT (skrót od Zulu).

Przy założeniu, że nadawca wysyła godzinę  2017-10-04 20:11:13 w strefie czasowej CEST (UTC + 2)

otrzymamy następującą datę:

2017-10-04 18:11:13Z

Tak sformatowaną datę możemy spokojnie wysłać na serwer. Aplikacja która odbierze tę informację, musi najpierw odczytać strefę czasową w której została zapisana data, następnie obliczyć poprawny czas zgodnie z lokalną strefą i zaprezentować ją użytkownikowi.

Z pomocą przychodzi nam ponownie DateFormatter:

func stringToDate(fromString string: String) -> Date? {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ssz"
    return dateFormatter.date(from: string)
}

symbol ‘z’ w tym przypadku informuje, że w tym miejscu w stringu zapisana została strefa czasowa

Warto zwrócić uwagę na to iż w tym przypadku korzystamy z symbolu “z” natomiast w poprzednim przypadku korzystaliśmy z “Z” - duże Z.

mimo tego, ze jest to ta sama litera, różnica jest znacząca

“z” - to symbol informujący DateFormatter, że w danym miejscu w Stringu będzie zawarta informacja o strefie czasowej, zapisanej w sposób skrócony

“Z” - to konkretnie czas UTC, dopisanie Z na końcu Stringu opisującego czas oznacza że jest to czas zapisany w UTC, Z to skrót od Zulu, czyli wojskowe określenie południka zero

Ponieważ Date jest nieczytelny, aby zaprezentować użytkownikowi wczytaną datę należy skonwertować ją na ciąg znaków, przeliczając przy okazji na lokalną strefę czasową:

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
 
if let date = stringToDate(fromString: stringDate) {
    print(dateFormatter.string(from: date))
}

Jeżeli użytkownik znajduje się w Poznaniu (CEST+2), rezultatem takiej operacji będzie czas 2017-10-04 20:11:13

Jeśli wiadomość została wysłana do użytkownika w Nowym Yorku, strefa czasowa EDT = UTC - 4, otrzyma on następujący wynik: 2017-10-04 14:11:13

Mamy to !

Z powyższego kodu moglibyśmy stworzyć następujące extensions:

extension Date {
   struct Formatter {
       static let utcFormatter: DateFormatter = {
           let dateFormatter = DateFormatter()

           dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss'Z'"
           dateFormatter.timeZone = TimeZone(identifier: "GMT")

           return dateFormatter
       }()
   }

   var dateToUTC: String {
       return Formatter.utcFormatter.string(from: self)
   }
}

extension String {
   struct Formatter {
       static let utcFormatter: DateFormatter = {
           let dateFormatter = DateFormatter()
           dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ssz"
           
           return dateFormatter
       }()
   }

   var dateFromUTC: Date? {
       return Formatter.utcFormatter.date(from: self)
   }
}

Operując na datach w przypadku aplikacji mobilnych trzeba być bardzo ostrożnym szczególnie gdy wysyłamy je na serwer.

Aby posługiwać się czasem uwzględniającym strefy czasowe, należy pamiętać o tym aby wysyłać go w formacie UTC, a po odebraniu przekonwertować go na lokalną strefę czasową.

Jeżeli uda nam się prawidłowo zaimplementować strefy czasowe, użytkownicy naszych aplikacji będą mogli swobodnie umawiać się z ludźmi spoza swojej strefy czasowej bez obawy o pomyłki ;)