iOS: Локализация и версионирование данных с Firebase Database

02 июня 2021

Firebase сейчас стал одной из основных зависимостей, которую iOS разработчик добавляет в свой проект. И если Analytics, Crashlytics, и даже Performance работают из коробки или с минимальной настройкой, есть один инструмент, вызывающий куда больше вопросов. Это Firebase Realtime Database.

Как настроить ее для работы с разными локализациями?

Как настроить ее для работы в production?

Что насчет cреды и версионирования?

И главное, как сделать все это без костылей, и получить масштабируемый и тестируемый результат?

Мы кратко рассмотрим, как вообще работать с текстовыми значениями, от простейшего случая без локализации, до полноценной локализованной базы на Firebase.

 

Часть 1. У вас есть файл Labels?

Работая над iOS проектом вам, рано или поздно, будет необходимо решить, как организовать и хранить все Strings, используемые в лэйблах, кнопках и текстах. Сегодня уже никто не хардкодит тексты прямо в UI:


welcomeLabel.text = "Welcome to my cool App!"

Вероятнее всего, у вас где-нибудь в проекте есть файл Labels.swift, в котором все stings хранятся в организованном и легкодоступном виде:


struct Labels {
  static let welcomeLabel = "Welcome to my cool App!"
  static let loginButton = "log in"
}

Возможно, этот подход даже мутировал во что-то еще более структурированное:


enum Labels {  
  enum Login {
    static Let welcomeLabel = "Welcome to my cool app!"
    static Let loginButton = "login"
  }
}

Заметили, как Struct поменялся на Enum? В данном случае это имеет смысл, поскольку мы используем статичные свойства. Незачем давать себе или другим разработчикам доступ к инициализатору, которые Struct имеют по умолчанию, так как мы не собираемся создавать образец Labels.

При таком подходе можно легко назначить текстовое значение элементу интерфейса:


welcomeLabel.text = Labels.Login.welcomeLabel

Теперь все stings хранятся в одном месте. Вероятно, это будет достаточно для небольшого проекта или стартапа в стадии разработки MVP.

Однако однажды вы захотите добавить еще один язык интерфейса. Так что давайте поговорим о локализации.

 

Часть 2. Локализация

Добавить локализацию в проект довольно просто. Идем в Project -> Info -> Localization -> нажимаем кнопку + и выбираем нужную нам локализацию.

Добавляем локализацию

Теперь необходимо создать файл Localized.strings, который будет хранить тексты для всех поддерживаемых языков. Идем в File -> New -> File, выбираем Strings File, и называем его Localized.strings.

Добавляем Localized.strings

Наконец, выбираем только что созданный Localized.strings в навигаторе файлов, нажимаем Localize в инспекторе и выбираем нужные языки для локализации.

Localize strings

Доступны будут только те языки, которые вы добавили как локализацию в настройках проекта на прошлом шаге

У вас должен получиться Localized.strings для всех поддерживаемых локализаций:

Остался один вопрос. Как это будет работать в связке с уже существующим в нашем проекте Labels? И вот здесь мы приблизились к моменту, когда такого рода локализация не всегда является лучшим решением.

Во-первых, нужно добавить все ваши текстовые значения в Localizable.strings для всех ваших локализаций. Сначала без кавычек пишется название ключа (например, welcomeLabel), и после знака = добавляетя его значение для конкретной локализации.


welcomeLabel = "welcome to my cool app";
loginButton = "login";

Localizable.strings (English)


welcomeLabel = "Добро пожаловать!";
loginButton = "Войти";

Localizable.strings (Русский)

Далее нужно привязать локализованные ключи к свойствам Labels:


enum Labels {  
  enum Login {
    static Let welcomeLabel = NSLocalizedString("welcomeLabel", comment: "")
    static Let loginButton = NSLocalizedString("loginButton", comment: "")
  }
}

Ключ в Localized.strings должен совпадать с ключом в NSLocalizedString. Текстовое значение будет взято из файла Localized.strings в зависимости от текущей локализации.

Если создать дополнительное расширение для String и перенести туда инициализатор для NSLocalizedString , можно получить более привлекательную версию:


extension String {
    var localized: String {
        NSLocalizedString(self, comment: "")
    }
}

Расширение для String


enum Labels {  
  enum Login {
    static Let welcomeLabel = "welcomeLabel".localized
    static Let loginButton = "loginButton".localized
  }
}

Стоит отметить, что при таком подходе вам уже не придется идти и руками менять значения текстов там, где вы их используете. Код ниже по прежнему будет работать, поскольку локализация проводится непосредственно в Labels.


welcomeLabel.text = Labels.Login.welcomeLabel

В зависимости от сложности проекта и ваших планов на него, данного решение может быть вполне достаточно. В конце концов, это решение из коробки, которое нам предлагает Xcode, так что же может пойти не так?

Давайте разберемся подробнее.

  • необходимо внимательно соотносить ключи между Localized.strings и Labels.swift . Если ключи различаются, или вы забыли предоставить значение для ключа в Localized.strings, конечный пользователь увидит в интерфейсе сам ключ вместо локализованного текста.
  • Файл Localized.strings имеет такой формат, что достаточно малейшей ошибки при его редактировании, чтобы он стал нечитаемым. Если у вас огромный Localized.strings и вы пропустили ; в конце одной из строчек… Что ж, удачи вам найти эту ошибку. Xcode не говорит по этому поводу ничего внятного.

Где-то не хватает точки с запятой

  • Localized.strings это текстовый файл, поэтому в нем нет возможности создавать структуру, как мы сделали в файле Labels.swift. Это вызывает дополнительные сложности, ведь когда файл становится достаточно большим, работать с ним практически невозможно. Можно воспользоваться генераторами кода, чтобы не редактировать этот файл вручную, но добавлять ключи все еще механическая ручная работа, которая может привести к ошибкам.
  • И самое критичное, что однажды может возникнуть необходимость изменить отдельные тексты. Например, вместо “Добро пожаловать в приложение!” вы решите сделать “Доброе утро! Используя текущее решение, вам придется вручную обновить тексты в Localized.strings для всех поддерживаемых языков, а потом выпустить новую версию приложения в сторе. Звучит как-то неоправданно сложно для простого изменения теста, не правда ли?

Если вы столкнулись с любой и вышеперечисленных проблем, значит пришло время использовать решение с удаленной базой данных.

Часть 3. Удаленная база данных текстов

В этой части мы настроим Firebase Database как удаленную базу, которая будет хранить все тексты для вашего приложения. Такое решение избавит вас от большинство проблем с локальными текстами. Вам больше не придется поддерживать несколько языков в приложении, ровно как и обновлять ключи от всех текстов в Localized.strings. Но самое главное, теперь вы сможете в несколько кликов обновить данные в базе без необходимости выкатывать новую версию приложения. Само собой, такое решение тоже имеет свои сложности.

  • Локализация никуда не делась, она просто переехала в другое место. Теперь необходимо ее настроить в Firebase Database
  • Необходимо также настроить и само приложение, чтобы оно скачивало правильные тексты в зависимости от текущей локализации
  • Удаленная база данных требует настройку среды и версионирования (об этом поговорим чуть позже)

Давайте разберемся, как это работает.

Как настроить Firebase Database

В этой статье мы не будем подробно останавливаться на том, как добавить и настроить Firebase в вашем проекте. Сам Firebase предлагает очень подробные пошаговые инструкции. Давайте сразу перейдем к Firebase Database.

Firebase Database это облачный сервис, который предоставляет доступ к noSQL базе данных. Данные хранятся в формате JSON, и Firebase имеет API для работы с ними.


Database.database().reference().observeSingleEvent(of: .value, with: { snapshot in
    // do something with value
 }) { error in
    // handle error
 }

Не забудьте импортировать библиотеку FirebaseDatabase.

В примере выше можно также использовать другой метод, DatabaseReference.observe. Он работает как сокет соединение, и дает вам преимущества Realtime Database: при изменении данных в Database ваши пользователи будут мгновенно получать обновления внутри блока observe. В нашем случае сокет не нужен, мы будем единовременно загружать данные при старте приложения, поэтому observeSingleEvent будет достаточно.

Если вы используете веб сервисы в приложении, то вы уже наверняка знакомы с форматом JSON. Swift позволяет легко распарсить JSON в ваши доменные модели с помощью протокола Decodable.

Прежде чем перейти к загрузке текстов из Firebase, нам необходимо внести несколько изменений в Labels.swift.


struct Labels: Decodable {
    let login: Login
  
    struct Login: Decodable {
        let welcomeString: String
        let loginButton: String
    }
}

Вместо хранилища данных этот файл теперь будет структурой, выполняющей протокол Decodable. Обратите внимание, нам понадобится именно образец этой структуры, поэтому мы больше не используем статические свойства.

Воспользуемся API Firebase и новой структурой Labels, чтобы загрузить и распарсить тексты:


Database.database().reference().observeSingleEvent(of: .value, with: { snapshot in
    guard let value = snapshot.value, JSONSerialization.isValidJSONObject(value) else {
        // no JSON data received
        return
    }
    do {
        let data = try JSONSerialization.data(withJSONObject: value)
        let labels = try JSONDecoder().decode(Labels.self, from: data)
        // Labels are ready to use in the app
    } catch let error {
        // handle decoding error
    }
}) { error in
    // handle Firebase error
}

Теперь перейдем в Database и создадим новую базу данных для хранения текстов:

Пример текстов в Firebase Database

Графическое представление базы на Firebase это не что иное, как вот такой JSON файл:


{
  "login": {
      "welcomeLabel": "welcome to my cool App!",
      "loginButton": "Login"
  }
}

Теперь вы можете загрузить этот JSON и распарсить его в Labels. Поздравляю, у вас работающая удаленная база данных!

Но как насчет локализации, версионирования и других фишках, которые мы описывали выше?

Firebase Database дает возможность загружать только часть базы данных, использую концепцию ветвей (при рассмотрении базы в Firebase можно заменить, что по структуре она напоминает дерево). Например, ветка login на скриншоте выше включает в себя welcomeLabel и loginButton .

В нашем примере мы может запросить из базы данных только Labels.Login используя DatabaseReference с веткой login:


Database.database().reference().child("login").observeSingleEvent(of: .value, with: { snapshot in
    // access to Labels.Login                                                                        
}) { error in
    // handle Firebase error
}

Убедитесь, что ключ “login” совпадает с веткой в базе, по которому вам необходимо получить данные.

Давайте используем эти знания, чтобы добавить несколько локализаций. Также мы добавим ветвь Labels, чтобы база была более организована:

Теперь нам лишь необходимо сказать iOS приложению, по какому ключу взять данные из базы, в зависимости от локализации: ENG или RUS. На самом деле, для этого уже есть механизм. Это Localized.strings. Только вместо текстовых значений, мы укажем здесь ключ для база данных.


languageCode = "ENG";

Localized.strings (English)


languageCode = "RUS";

Localized.strings (Russian)

Теперь мы можем использовать Localized.strings, чтобы создать путь в нужную часть базы данных:


Database.database().reference()
    .child("labels")
    .child("languageCode".localized)
    .observeSingleEvent(of: .value, with: { snapshot in
     // access to localized Labels                                                                        
}) { error in
     // handle Firebase error
}

Осталось только загрузить данные в Firebase Database. Согласитесь, не очень удобно вводить тексты вручную в консоли Firebase. Вместо этого можно загрузить туда уже готовый JSON файл. Чтобы создать этот файл, мы сделаем дефолтные образцы Labels для каждой локализации.


let DefaultLabelsENG = Labels(
    login: Labels.Login(
        loginButton: "login",
        welcomeLabel: "Welcome to my cool App!"
    )
)

let DefaultLabelsRUS = Labels(
    login: Labels.Login(
        loginButton: "Войти",
        welcomeLabel: "Добро пожаловать!"
    )
)

При таком подходе, компилятор позаботится о том, чтобы вы не пропустили и не перепутали ключи в создаваемом JSON. Все ключи в JSON будут соответствовать названиям свойств в структуре Labels. Нужно лишь сгенерировать JSON.

Генератор JSON является отдельным инструментом, поэтому имеет смысл создать отдельный target в приложении. Назовем его LabelsParser. Теперь можем переместить туда DefaultLabelsENG и DefaultLabelsRUS. Ниже представлен пример того, как можно сгенерировать JSON файл и сохранить его на компьютер.


func saveENG() {
    do {
        let data = try JSONEncoder().encode(DefaultLabelsEng)
        let filePath = getDocumentsDirectory().appendingPathComponent("LabelsENG.json")
        try data.write(to: filePath)
    } catch {
        fatalError(error.localizedDescription)
    }
}

func saveRUS() {
    do {
        let data = try JSONEncoder().encode(DefaultLabelsRus)
        let filePath = getDocumentsDirectory().appendingPathComponent("LabelsRUS.json")
        try data.write(to: filePath)
    } catch {
        fatalError(error.localizedDescription)
    }
}

private func getDocumentsDirectory() -> URL {
    let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
    return paths[0]
}

Убедитесь, что структура Labels и все вложенные в него структуры выполняют протокол Codable. Также необходимо, чтобы DefaultLabelsENG.swift и DefaultLabelsRUS.swift входили в таргет LabelsParser

В консоли Firebase Database идем в базу, открываем ключ ENG/labels, выбираем Import JSON из меню справа, находим только что сгенерированный файл LabelsENG.json и импортируем его. В случае успешной загрузки Firebase обновит базу. Аналогичным образом загружаем файл LabelsRUS.json по ключу RUS/labels.

Если с выбранным файлом что-то не так, вы увидите ошибку и база не обновится.

Таким образом, обновление текстов в приложении происходит в 4 шага:

  1. Обновляем структуру Labels согласно вашему плану (добавляем, изменяем или удаляем свойства или вложенные структуры)
  2. Xcode покажет ошибки в DefaultLabelsENG и DefaultLabelsRUS, поэтому нужно обновить эти файлы, предоставив дефалтные значения
  3. Генерируем новые JSON файлы с помощью LabelsParser
  4. Загружаем JSON файлы в Firebase по нужным ключам локализации

Если нужно обновить базу “на лету”, вы можете сделать это напрямую в Firebase, и все пользователи получат обновление при следующем запуске приложения. Или не получат?

Можем ли мы менять текущую структуру JSON в базе, удалять или переименовывать ключи? Ведь пользовательские приложения ожидают конкретную структуру и конкретные ключи, соответствующие модели Labels. При любых различиях модели Labels и JSON файла в базе, парсинг JSON закончится ошибкой.
Если у вас возник такой вопрос, значит пора поговорить о среде (environment) и версионировании (versioning) базы данных.

Среда и версионирование базы данных

Прежде, чем мы продолжим говорить о среде и версионировании, можно сделать еще одно улучшение, чтобы исключить “падение” приложения при изменении базы данных по любым причинам. Нужно предоставить дефалтные текстовые значения. У нас уже есть файл DefaultLabelsENG , из которого мы генерируем JSON. Поэтому нужно лишь сказать вашему приложению использовать его, если подключение к Firebase или парсинг JSON закончится с ошибкой.


private func getData(for reference: DatabaseReference, completion: @escaping (Labels) -> Void) {
    reference.observeSingleEvent(of: .value, with: { snapshot in
        guard let value = snapshot.value, JSONSerialization.isValidJSONObject(value) else {
            // user default labels
            completion(DetaultLabelsENG)
            return
        }
        do {
            let data = try JSONSerialization.data(withJSONObject: value)
            let labels = try JSONDecoder().decode(Labels.self, from: data)
            completion(labels)
        } catch {
            // user default labels
            completion(DetaultLabelsENG)
        }
    }) { _ in
        // user default labels
        completion(DetaultLabelsENG)
    }
}

Здесь можно также позаботится о том, чтобы использовать соответствующие дефалтные значения при каждой локализации

Теперь вернемся к среде и версионировании базы. Любая база данных должна иметь по крайней мере две среды: development и production. Так же можно встретить Staging, Beta и другие, в зависимости от конфигурации вашего проекта. Суть в том, что среда production используется исключительно для работы с “живыми” пользователями приложения. При этом development среда предназначена для разработки и тестирования, и ее изменения не столь критичны для бизнеса.

Чтобы разграничить среду в базе Firebase, мы используем уже знакомый подход разделения базы по ветвям. В корне вашей базы теперь будут environment, далее локализации, и, наконец, сама база, которую будет получать приложение.

Development и Production

Чтобы iOS приложение понимало, в какую ветку базы идти за текстами, можно использовать #if DEBUG при создании пути для Firebase Database:


#if DEBUG
let path = Database.database().reference().child("development").child("languageCode".localized)
#else
let path = Database.database().reference().child("production").child("languageCode".localized)
#endif

Вместо проверки #if DEBUG можно использовать конфигурационные файлы .xcconfig для development и production среды приложения. Такой подход будет более гибким и ясным, хотя он немного более сложный в реализации. Для нашего примера мы остановимся на проверке #if DEBUG

Разделение среды на development и production это хорошо, но как насчет поддержки нескольких версий приложения? Например, текущая версия приложения 1.0.0, и именно эта база данных находится в production ветке базы Firebase. Далее вы выпускаете версию 1.1.0, которая включает некоторые изменения в файле Labels. Если вы сгенерируете обновленный JSON и загрузите его в Firebase, то все пользователи с версией приложения 1.0.0 не смогут загрузить тексты, так как парсинг JSON закончится ошибкой. Для избежания такой ситуации применяется понятия версионирования базы данных.

Под версиониованием базы понимают поддержку нескольких схем базы (в случае с Firebase, несколько вариантов JSON), которые соответствуют версиям приложения.

Прежде всего, подготовим iOS проект для поддержки версионирования. Для этого создадим Environment enum:


enum Environment {
    case development
    case production(version: String)
    
    var stringValue: String {
        switch self {
        case .debug:
            return "development"
        case let .production(version: version):
            return "production/\(version)"
        }
    }
}

В этом случае, мы учитываем как среду, так и версию для для production. stringValue будет создавать путь к базе в зависимости от среды и версии приложения.

С учетом вышеизложенного, можем собрать отдельную сущность Endpoint, которая будет отвечать за настройку и выбор правильного пути к базе, в зависимости от локализации, среды, версии и части базы, которую нужно загрузить.


struct Endpoint {
    private let reference: DatabaseReference
    private let locale: String
    
    enum Environment {
        case development
        case production(version: String)
        
        var stringValue: String {
            switch self {
            case .debug:
                return "development"
            case let .production(version: version):
                return "production/\(version)"
            }
        }
    }
    
    enum Path: String {
        case labelsKey = "labels"
    }
    
    init(locale: String, environment: Environment) {
        self.locale = locale
        self.reference = Database.database().reference().child(environment.stringValue)
    }
    
    var labelsReference: DatabaseReference {
        return reference.child(locale).child(Path.labelsKey.rawValue)
    }
}

endpoint.labelsReference в данном случае это полный путь к текстовым значениям.

Используя Endpoint, создадим также WebService, который будет загружать labels:


struct WebService: WebServiceProvider {
    
    private let endpoint: Endpoint
    
    init(locale: String, environment: Endpoint.Environment) {
        endpoint = Endpoint(locale: locale, environment: environment)
        endpoint.reference.keepSynced(true)
    }

    func getLabels(completion: @escaping (Labels) -> Void) {
        endpoint.labelsReference.observeSingleEvent(of: .value, with: { snapshot in
            guard let value = snapshot.value, JSONSerialization.isValidJSONObject(value) else {
                // user default labels
                completion(DetaultLabelsENG)
                return
            }
            do {
                let data = try JSONSerialization.data(withJSONObject: value)
                let labels = try JSONDecoder().decode(Labels.self, from: data)
                completion(labels)
            } catch {
                // user default labels
                completion(DetaultLabelsENG)
            }
        }) { _ in
            // user default labels
            completion(DetaultLabelsENG)
        }
    }
}

Остается только создать инстанс WebService:


#if DEBUG
let environment = Environment.development
#else
let environment = Environment.production(version: "1-0-0")
#endif

let webService = WebService(locale: "languageCode".localized, environment: environment)

webService.getLabels { labels in
   // Labels are here, remote copy or local default copy                      
}

Можно также использовать Bundle.main.infoDictionary?["CFBundleShortVersionString"] как текущую версию приложения, вместо того, чтобы указывать её вручную. При этом необходимо следить за тем, чтобы база Firebase имела соответствующую версию, и формат совпадал (1.0.0 и 1–1–0 это не одно и тоже)

Финальная версия базы

Данное решение позволяет в короткие сроки создать и использовать масштабируемую удаленную базу с поддержкой локализации, среды и версионирования.

Не лишним будет добавить тесты для поддерживаемых путей к базе. В нашем примере можно проверить, чтобы Endpoint.labelsReference совпадал с URL, который можно найти в Firebase для данного пути:

Что вы думаете о таком решение? Как вы управляете текстами в своих проектах?

Автор статьи: Stan Ostrovskiy

Содержание