Dynamiczna lokalizacja powiadomień na iOS, czyli jak wysyłać wiadomości w różnych językach

Dynamiczna lokalizacja powiadomień na iOS, czyli jak wysyłać wiadomości w różnych językach

 

Jak ustanowić łącznik pomiędzy użytkownikiem, dostawcą a aplikacją? Wykorzystaj powiadomienia systemowe. Dzięki nim użytkownik szybko otrzymuje najnowsze informacje. To rozwiązanie łatwo zastosujesz w aplikacjach zorientowanych na jeden rynek. Problemy zaczynają się, gdy treści muszą być tworzone w różnych językach. Wtedy pomocą służy dynamiczna lokalizacja zdalnych powiadomień bez wykorzystania własnego serwera.

Opisywane tutaj rozwiązanie najlepiej się sprawdzi w przypadku aplikacji bez serwerowego API, które może przechowywać informacje o tym, jaki jest język systemu lub aplikacji.

Gdzie zamieszczać treści powiadomień?

Zazwyczaj tekst wiadomości przechowywany jest na dwa sposoby. Przede wszystkim możesz zamieścić go w plikach .strings – to proste i wygodne rozwiązanie, pod warunkiem, że tłumaczenia są z góry zdefiniowane i skompilowane z aplikacją.

Ta opcja nie sprawdzi się jednak w przypadku bardziej deskryptywnych powiadomień, np. kiedy chcesz, żeby w treści znalazł się fragment newsa powiązanego z daną lokalizacją. Na szczęście istnieje alternatywny sposób, który można w takiej sytuacji wykorzystać.

Kiedy aplikacja działa w wielu wersjach językowych, powiadomienia muszą być najpierw przetłumaczone. Taki proces nazywa się lokalizowaniem lub internacjonalizowaniem. Twoim zadaniem jest właśnie lokalizowanie treści powiadomień. A ja pokażę Ci, jak to zrobić.

Rozszerzenie, które umożliwia dynamiczną lokalizację

Rozszerzenie Notification Service Extension odgrywa tutaj kluczową rolę. Jak ono działa? W dużym skrócie, pozwala na wprowadzanie zmian w payloadzie jeszcze zanim system wyświetli powiadomienie. Twoim zadaniem jest podmiana payloadu na taki, który będzie odpowiedni dla lokalizacji (czyli wersji językowej) ustawionej w systemie bądź w samej aplikacji.

Ustawienie tego samego języka dla aplikacji i systemu

To proste, gdy tłumaczenie aplikacji opiera się na rozwiązaniach natywnych, tj. bazuje na ustawieniach języka, które są zdefiniowane w systemie iOS. Wtedy wystarczy skorzystać z klasy Locale, co pozwoli Ci uzyskać listę preferowanych języków albo informację o tym, jaki jest język systemu.

Jak zmienić język powiadomienia na inny niż systemowy?

Sytuacja się komplikuje, kiedy chcesz umożliwić ustawienie innego języka aplikacji niż ten, który jest zdefiniowany jako systemowy. Rozszerzenie nie ma bowiem bezpośredniego dostępu do aplikacji (oraz jej sandboxa) i vice versa. Jak sobie z tym poradzić?

Z pomocą przychodzi inne rozwiązanie systemowe – App Groups, a dokładnie App Group Shared Container. To miejsce, do którego mają dostęp rozszerzenia i aplikacje z tego samego zespołu deweloperskiego, czyli te, które korzystają z tego samego Certyfikatu do podpisywania kodu. We wspólnym kontenerze można przechowywać m.in. pliki baz danych (np. CoreData) czy UserDefaults.

Na potrzeby tego artykułu korzystałem właśnie z UserDefaults, ponieważ w mojej opinii to rozwiązanie wymaga najmniej dodatkowej wiedzy. Nie widzę też potrzeby strzelania z armaty (czytaj: korzystania z bazy danych typu CoreData) do muchy, czyli żeby umożliwić przechowywanie identyfikatora wybranego języka.

Zakładam, że masz już Certyfikat Developerski w Apple Developer. Jeśli jeszcze go nie utworzyłeś, zrób to tutaj, zanim przejdziemy do pierwszego etapu.

Konfiguracja środowiska

App Identifiers, Profiles, App Groups

Uwagi ogólne

Po pierwsze, aby móc uruchomić ten projekt, potrzebujesz profili z Apple Developer. Remote Push Notifications nie działają na symulatorach, dlatego musisz mieć pod ręką fizyczne urządzenie.

Druga kwestia dotyczy tworzenia profili i identyfikatorów aplikacji. Prawdopodobnie wiesz, jak poruszać się po Apple Developer, dlatego nie będę wchodził w szczegóły, jeśli chodzi o generowanie App Identifiers oraz App Profiles (kolejność nie jest przypadkowa). Ale jeżeli nie czujesz się w tym zbyt pewnie, zajrzyj tutaj do sekcji Maintain signing assets.

Rejestracja ID

Zacznij od rejestracji App Identifiers dla aplikacji i rozszerzenia. Identifier dla aplikacji powinien mieć włączone dwie opcje w sekcji Capabilities:

  • Push Notifications,
  • App Groups.

Natomiast Identifier dla rozszerzenia wymaga tylko jednego capability – App Group.

Rejestracja kontenera

Żeby aplikacja i rozszerzenie mogły się ze sobą komunikować, potrzebujesz łącznika (App Group) w postaci wspólnego kontenera, o którym wspominałem na początku. Aby z niego skorzystać, musisz go zarejestrować w Apple Developer. Oto krótka instrukcja, jak to zrobić:

  1. Zaloguj się do Apple Developer.
  2. Przejdź do sekcji Certificates, IDs & Profiles.
  3. Przejdź do sekcji Identifiers.
  4. Kliknij przycisk +, który znajduje się na górze, przy nagłówku sekcji.
  5. Wybierz pozycję App Groups z listy i uzupełnij puste pola.

Nazwy mogą oczywiście brzmieć dowolnie, ale dobre praktyki sugerują stosowanie poniższego schematu dla Identifiera:

group + odwrócona domena + nazwa aplikacji

Po stworzeniu grupy dodaj ją jako capability dla App Identifier aplikacji i rozszerzenia. W tym celu przejdź do App Identifier. W sekcji Capabilities, obok App Groups, znajduje się przycisk Edit. Kliknij w niego i zaznacz grupę, którą stworzyliśmy wcześniej. To samo działanie musisz wykonać zarówno dla Identifiera aplikacji, jak i rozszerzenia. Na koniec wygeneruj dla nich profile developerskie.

Implementacja rozwiązania

Rejestracja grupy aplikacji

Utwórz nowy projekt w Xcode. Jeśli potrzebujesz gotowego kodu, skorzystaj z zasobów GitHuba.

Później wykonaj następujące kroki:

  1. W Xcode zaznacz target głównej aplikacji.
  2. Zaznacz zakładkę Signing and Capabilities.
  3. Odznacz Automatically manage signing (jeśli ta opcja jest zaznaczona).
  4. Z sekcji Provisioning Profile wybierz wcześniej wygenerowany profil dla aplikacji.
  5. W górnym lewym rogu kliknij Capability.
  6. Następnie z listy wybierz App Groups.
  7. Na liście zaznacz grupę, którą wcześniej utworzyłeś. Jeśli jej nie ma, kliknij znak +. 

Następnie wpisz identyfikator grupy, którą utworzyłeś w poprzedniej sekcji.

Przykładowo, w moim przypadku wygląda to tak:

Dodawanie rozszerzenia

Kiedy masz już zarejestrowaną grupę dla aplikacji, możesz stworzyć to, co interesuje Cię najbardziej, czyli rozszerzenie do modyfikacji powiadomienia.

W kolumnie Target kliknij symbol +, który znajduje się w lewym dolnym rogu.

Następnie w polu wyszukiwania wpisz Notification Service Extension.

Kliknij Next i podaj nazwę rozszerzenia. Może ona brzmieć dowolnie, ale najlepiej, jeśli kojarzy się z funkcją, którą pełni. Inaczej po stworzeniu rozszerzenia nie będziesz mógł na pierwszy rzut oka określić, do czego ono służy.

Klikamy Finish. Wtedy wyświetli się pytanie, czy chcesz aktywować rozszerzenie. Wybierz Activate, a Xcode utworzy dla niego potrzebne pliki.

Ustawianie Provisioning Profile i App Group

Teraz masz rozszerzenie, ale musisz jeszcze ustawić Provisioning Profile oraz App Group, podobnie jak w przypadku głównej aplikacji.

  1. Zaznacz target rozszerzenia.
  2. Kliknij zakładkę Signing and Capabilities.
  3. Odznacz Automatically manage signing (jeśli ta opcja jest zaznaczona).
  4. Z sekcji Provisioning Profile wybierz wcześniej wygenerowany profil dla rozszerzenia.
  5. W górnym lewym rogu kliknij Capability, a następnie z listy wybierz App Groups.
  6. Zaznacz listę, którą wcześniej utworzyłeś. Jeśli jej nie ma, kliknij znak + i wpisz ten sam identyfikator grupy, który podałeś dla aplikacji głównej.

Warto przy okazji sprawdzić, czy aplikacja na pewno buduje się i instaluje na urządzeniu. Dodatkowo należy zweryfikować, czy masz możliwość otrzymywania wiadomości. W tym celu dodaj w AppDelegate.swift kod rejestrujący aplikację dla zdalnych powiadomień.

func application (
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        
    UNUserNotificationCenter
        .current()
        .requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
      if let error = error {
        print(error)
      } else {
        DispatchQueue.main.async {
            application.registerForRemoteNotifications()
        }
      }
    }

    return true
}

Na tym etapie możesz wypisać (ang. print) do konsoli nasz token po zarejestrowaniu. Tę funkcję dodajemy w pliku AppDelegate.swift.

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let deviceTokenString = deviceToken.map { String(format: "%02x", $0) }.joined()
    print("Remote Notification Token: \(deviceTokenString)")
}

Po uruchomieniu aplikacji wyraź zgodę na otrzymywanie powiadomień. Do sprawdzenia czy działają one poprawnie, korzystam z aplikacji Push Hero dostępnej w App Store. Jest łatwa w obsłudze, ale płatna.

Jeśli szukasz darmowego rozwiązania, skorzystaj z opensource’owej aplikacji Knuff lub stwórz skrypt do wysyłania wiadomości – tu masz pełną dowolność. Co ważne, payload musi zawierać pole mutable-content: 1. Bez tego parametru powiadomienia nie są przetwarzane przez rozszerzenie.

Przykładowy payload:

{
  "aps" : {
     "mutable-content" : 1,
     "alert" : {
        "title" : "Localized Push Title",
        "body" : "Push payload body"
    }
  }
}

Jeśli wszystko przebiegło zgodnie z planem, powinieneś zobaczyć takie powiadomienie:

Obok tytułu wyświetla się napis [modified]. To dla Ciebie informacja, że rozszerzenie działa prawidłowo.

import UserNotifications

class NotificationService: UNNotificationServiceExtension {

   var contentHandler: ((UNNotificationContent) -> Void)?
   var bestAttemptContent: UNMutableNotificationContent?

   override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
       self.contentHandler = contentHandler
       bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
       
       if let bestAttemptContent = bestAttemptContent {
           // Modify the notification content here...
           bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
           
           contentHandler(bestAttemptContent)
       }
   }
   
   override func serviceExtensionTimeWillExpire() {
       // Called just before the extension will be terminated by the system.
       // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
       if let contentHandler = contentHandler, let bestAttemptContent =  bestAttemptContent {
           contentHandler(bestAttemptContent)
       }
   }

}

A teraz moment, na który czekałeś – ustawianie języków. Aby zarządzanie wybranym językiem stało się łatwiejsze, stworzyłem klasę pomocniczą LanguageController.swift. Ważne, żeby jej target stanowiła zarówno aplikacja, jak i rozszerzenie, ponieważ będziesz wykorzystywał tę klasę w dwóch miejscach.

import Foundation

struct Language {
    let code: String
    let name: String
}

final class LanguageController {
    static let shared = LanguageController()
    
    enum Constansts {
        enum AppGroup {
            static let identifier = "group.com.holdapp.localized-apns-demo"
        }
        enum UserDefaults {
            static let selectedLanguageKey = "SelectedLanguageKey"
        }
    }
    
    let availableLanguages = [
        Language(code: "en", name: "English"),
        Language(code: "de", name: "Deutsch"),
        Language(code: "uk", name: "Український")
    ]
    
    var defaultLanguage: Language { availableLanguages.first! }
    
    private init() {}
    
    func geLanguage() -> Language {
        guard let appGroupDefaults = UserDefaults(suiteName: Constansts.AppGroup.identifier) else {
            fatalError("Can't find app group with identifier: \(Constansts.AppGroup.identifier)")
        }
        
        if
            let selectedLanguage = appGroupDefaults.string(forKey: Constansts.UserDefaults.selectedLanguageKey),
            let language = availableLanguages.first(where: { $0.code == selectedLanguage })
        {
            return language
        }
        
        return defaultLanguage
    }
    
    func setLanguage(_ code: String) {
        guard let appGroupDefaults = UserDefaults(suiteName: Constansts.AppGroup.identifier) else {
            fatalError("Can't find app group with identifier: \(Constansts.AppGroup.identifier)")
        }
        
        appGroupDefaults.setValue(code, forKey: Constansts.UserDefaults.selectedLanguageKey)
    }
}

Przykładowy payload dla powiadomień będzie wyglądał tak, jak na przykładzie poniżej. Tutaj również panuje dowolność w kwestii nazewnictwa, ale warto pamiętać, że wszystkie customowe klucze powinny być definiowane na poziomie klucza aps.

   "aps" : {
      "mutable-content" : 1,
      "alert" : {
         "title" : "Default Message Title",
         "body" : "Default Message Body"
      }
   },
   "localized-message": {
       "en": {
           "title": "Message Title",
           "body": "Message Body"
       },
       "de": {
           "title": "Nachrichtentitel",
           "body": "Nachrichtentext"
       },
       "uk": {
           "title": "Заголовок повідомлення",
           "body": "Тіло повідомлення"
       }
   }
}

 

Może się zdarzyć, że w konsoli zobaczysz komunikat podobny do tego:

[User Defaults] Couldn't read values in CFPrefsPlistSource<0x281ced500> (Domain: group.com.holdapp.localized-apns-demo, User: kCFPreferencesAnyUser, ByHost: Yes, Container: (null), Contents Need Refresh: Yes): Using kCFPreferencesAnyUser with a container is only allowed for System Containers, detaching from cfprefsd.

Jeśli tak się stanie, po prostu go zignoruj. Jeżeli chcesz wiedzieć więcej, tutaj znajdziesz potrzebne informacje.

Kod do pobrania wiadomości, która Cię interesuje, prezentuje się następująco:

extension LanguageController {
    func extractLocalizedMessage(from userInfo: [AnyHashable: Any], for code: String) -> [String: String]? {
        guard
            let messages = userInfo[Constansts.Push.localizedMessageKey] as? [String: Any],
            let messageObject = messages[code] as? [String: String]
        else {
            return nil
        }
        
        return messageObject
    }
}

Korzystając z powyższego kodu, możesz zaimplementować obsługę powiadomień w klasie obsługującej rozszerzenie.

import UserNotifications

class NotificationService: UNNotificationServiceExtension {

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        
        let langCode = LanguageController.shared.getLanguage().code
        
        if
            let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent),
            let extractedMessage = LanguageController.shared.extractLocalizedMessage(from: request.content.userInfo, for: langCode),
            let title = extractedMessage[LanguageController.Constansts.Push.titleKey],
            let body = extractedMessage[LanguageController.Constansts.Push.bodyKey]
        {
            
            mutableContent.title = title
            mutableContent.body = body
            
            contentHandler(mutableContent)
        } else {
            contentHandler(request.content)
        }
    }
    
    override func serviceExtensionTimeWillExpire() {}
}

Na koniec warto pamiętać, że jeśli z jakiegoś powodu nie uda się uzyskać z payloadu tłumaczenia wiadomości, system wyświetli treść z obiektu aps. Dlatego należy ją aktualizować przy wysyłaniu informacji.

 

Powiadomienia odgrywają ważną rolę w komunikacji marketingowej i pozwalają szybko nawiązać kontakt z użytkownikiem. Zachęcają go przy tym do częstszego korzystania z aplikacji.

Jeśli rozwijasz komercyjną aplikację na kilka rynków, wypróbuj to rozwiązanie. Pobierz cały kod z Githuba i wykorzystaj go w swoim projekcie.

Lukasz - portrait

Łukasz Szyszkowski

iOS/Mac Developer z ponad 10-letnim doświadczeniem. Nie ogranicza się tylko do technologii Apple’a – jeśli trzeba, korzysta też z Pythona i Ruby. Łukasz lubi rozwiązywać problemy związane z programowaniem i chętnie udziela pomocy innym. Miłośnik dobrej kuchni, który czerpie przyjemność z kulinarnych eksperymentów. W słoneczne dni można go spotkać na szlaku, jadącego na rowerze albo odbywającego piesze wędrówki.

Dowiedz się więcej

Wycena projektu

Opowiedz nam o swoim projekcie i napisz, jak możemy Ci pomóc.

Dlaczego warto rozwijać z nami projekty?

Logo Mobile Trends Awards

Mobile Trends Awards 2021

Wygrana w kategorii
ŻYCIE CODZIENNE

Nagroda Legalnych Bukmacherów

Nagroda Legalnych Bukmacherów 2019

Najlepsza aplikacja mobilna

Mobile Trends Awards logo

Mobile Trends Awards 2023

Wygrana w kategorii
MCOMMERCE ROZWÓJ

23

opinie klientów

Clutch logo