company_banner

The New iOS Mobile Enterprise. Часть #1: Кодогенерация для ресурсов

    Всем привет!


    Меня зовут Дмитрий. Так получилось, что я являюсь тим лидом в команде из 13 iOS разработчиков уже на протяжении двух лет. И вместе мы трудимся над приложением Тинькофф Бизнес.


    Хочу поделиться с вами нашим опытом о том, как релизить приложение в неожиданный момент с максимальным набором фич или баг фиксами и при этом не поседеть.


    Расскажу о практиках и подходах которые помогли команде заметно ускориться в разработке и тестировании и заметно сократить количество стресса, багов, проблем при внеплановом или срочном релизе. #MakeReleaseWithoutStress.


    Поехали!


    Описание проблемы


    Представьте себе следующую ситуацию.


    Идет очередной релиз. Ему предшествовало регрессионное тестирование, тестировщики снова нашли место, в котором вместо текста в приложении отображается ID строки.


    Localization bug

    Это была одна из самых частых наших проблем с которой мы сталкивались.


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


    Но вы можете столкнуться с другими проблемами, которые мы поможем вам решить:


    • Падает приложение, потому что вы неправильно указали имя картинки и сделали force unwrap
      UIImage(named: "NotExist")!
    • Падает приложение, если storyboard не добавлен в target
    • Падает приложение, если создали контроллер из storyboard с несуществующим ID
    • Падает приложение, если создали контроллер из storyboard с существующим ID, но сделали приведение не к тому классу
    • Непредсказуемое поведение, если используете шрифт в коде, который не добавлен в info.plist, или файлу шрифта не проставлен target: возможен crash, а возможно и просто получение стандартного шрифта вместо того, который нужен. Developer Apple: Custom Fonts, Stackoverflow: crash
    • Падает приложение, если в storyboards указали контроллеру класс, которого не существует
    • Куча однообразного кода в котором создаются иконки, шрифты, контроллеры, вьюшки
    • Отсутствуют картинки, иконки в runtime, хотя название картинки есть в storyboard, но нет в assets
    • В storyboard используется шрифт, которого нет в info.plist
    • В приложении появляются ID строк, вместо локализации в неожиданных местах, из-за удаления строк в Localizable.strings (думали, что не используются)
    • Что-то еще, о чем забыл упомянуть, или мы еще не сталкивались.

    Причина → Следствие


    Почему это все происходит?


    Есть программный код, который компилируется. Если вы что-то написали не так (синтаксически, или неправильное название функции при вызове), то ваш проект просто не соберется. Это понятно, очевидно и логично.


    А как быть с такими вещами, как ресурсы?


    Они не компилируются, просто добавляются в bundle уже после компиляции кода. В связи с этим может возникать большое количество проблем в runtime, например, тот случай, что описан выше — со строками в локализации.


    Поиск решения


    Мы задумались, как подобные проблемы решаются вообще, и как мы можем это исправить. Я вспомнил одну из конференций Cocoaheads в mail.ru. Там был доклад про сравнение инструментов для кодогенерации.


    Посмотрев еще раз, что эти инструменты (библиотеки/фреймворки) из себя представляют, мы наконец-то нашли то, что было нужно.


    При этом, похожий подход уже годами используется разработчиками под Android. Google подумал о них и сделал им такой инструмент из коробки. А ведь нам Apple даже стабильный Xcode не может сделать...


    Оставалось выяснить только одно — какой именно инструмент выбрать: Natalie, SwiftGen или R.swift?


    У Natalie не было поддержки локализации, от него было решено сразу отказаться. У SwiftGen и R.swift были очень похожие возможности. Мы сделали выбор в пользу R.swift, просто исходя из количества звезд, зная о том, что в любой момент мы можем поменять на SwiftGen.


    Как работает R.swift


    Запускается pre-compile build phase скрипт, пробегает по структуре проекта и генерирует файл, под названием R.generated.swift, который нужно будет добавить в проект (о том, как это сделать мы детальнее расскажем в самом конце).


    Файл имеет следующую структуру:


    import Foundation
    import Rswift
    import UIKit
    
    /// This `R` struct is generated and contains references to static resources.
    struct R: Rswift.Validatable {
        fileprivate static let applicationLocale = hostingBundle.preferredLocalizations.first.flatMap(Locale.init) ?? Locale.current
        fileprivate static let hostingBundle = Bundle(for: R.Class.self)
    
        static func validate() throws {
            try intern.validate()
        }
    
        // ...
    
        /// This `R.string` struct is generated, and contains static references to 2 localization tables.
        struct string {
            /// This `R.string.localizable` struct is generated, and contains static references to 1196 localization keys.
            struct localizable {
                /// en translation: Активировать Apple Pay
                /// 
                /// Locales: en, ru
                static let card_actions_activate_apple_pay = Rswift.StringResource(key: "card_actions_activate_apple_pay", tableName: "Localizable", bundle: R.hostingBundle, locales: ["en", "ru"], comment: nil)
    
                // ...
    
                /// en translation: Активировать Apple Pay
                /// 
                /// Locales: en, ru
                static func card_actions_activate_apple_pay(_: Void = ()) -> String {
                    return NSLocalizedString("card_actions_activate_apple_pay", bundle: R.hostingBundle, comment: "")
                }
    
            }
        }
    }

    Использование:


    let str = R.string.localizable.card_actions_activate_apple_pay()
    print(str)
    > Активировать Apple Pay

    "Зачем нужен Rswift.StringResource?", — спросите вы. Я сам не понимаю, зачем его генерировать, но, как объясняют авторы, то нужен он для следующего: ссылка.


    Применение в реальных условиях


    Небольшое пояснение контента ниже:


    *Было — пользовались подходом какое-то время, в итоге, ушли от него
    *Стало — подход который используем при написании нового кода
    *Не было, но у вас может быть — подход, которого никогда не существовало в нашем приложении, но я встречал его в различных проектах, в те далекие времена, когда еще не работал в Tinkoff.ru.


    Localization


    Мы начали применять R.swift для локализации, это избавило нас от проблем, о которых мы писали в самом начале. Теперь, если поменялся id в локализации, то проект не соберется.


    *Это работает только при условии, если вы поменяли id во всех локализациях на другой. Если же в какой-то из локализаций осталась строка, то при компиляции будет warning, что данный id локализован не на всех языках.


    Warning

    Не было, но у вас может быть:
    final class NewsViewController: UIViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
            titleLabel.text = NSLocalizedString("news_title", comment: "News title")
        }
    }

    Было:
    extension String {
    
        public func localized(in bundle: Bundle = .main, value: String = "", comment: String = "") -> String {
            return NSLocalizedString(self, tableName: nil, bundle: bundle, value: value, comment: comment)
        }
    }
    
    final class NewsViewController: UIViewController {
    
        private enum Localized {
            static let newsTitle = "news_title".localized()
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
            titleLabel.text = Localized.newsTitle
        }
    }

    Стало:
    titleLabel.text = R.string.localizable.newsTitle()

    Images


    Теперь, если мы что-то переименовали в *.xcassets, и не поменяли в коде, то проект просто не соберется.


    Было:
    imageView.image = UIImage(named: "NotExist") // иконка не видна пользователям
    imageView.image = UIImage(named: "NotExist")! // crash
    imageView.image = #imageLiteral(resourceName: "NotExist") // crash

    Стало:
    imageView.image = R.image.tinkoffLogo() // иконка всегда видна пользователям

    Storyboards


    Было:
    let someStoryboardName = "SomeStoryboard" // Change to something else (e.g.: "somestoryboard") - get nil or crash in else
    let someVCIdentifier = "SomeViewController" // Change to something else (e.g.: "someviewcontroller") - get nil or crash in else
    let storyboard = UIStoryboard(name: someStoryboardName, bundle: .main)
    let _vc = storyboard.instantiateViewController(withIdentifier: someVCIdentifier)
    guard let vc = _vc as? SomeViewController else {
        // логируем ошибку в какой-нибудь хипстерский сервис, вроде Fabric или Firebase
        // или просто вызываем fatalError() ¯\_(ツ)_/¯}

    Стало:
    guard let vc = R.storyboard.someStoryboard.someViewController() else {
        // логируем ошибку в какой-нибудь хипстерский сервис, вроде Fabric или Firebase
        // или просто вызываем fatalError() ¯\_(ツ)_/¯
    }

    И так далее.


    Валидация Storyboard


    R.validate() — это замечательный инструмент, который бьет по рукам (вернее просто выкидывает error в catch блок), если вы сделали что-то не так в storyboard или xib файлах.
    Например:


    • Указали название картинки, которой нет в проекте
    • Указали шрифт, а потом перестали его использовать и удалили его из проекта (из info.plist)

    Использование:


    final class AppDelegate: UIResponder {
    
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]? = nil) -> Bool {
            #if DEBUG
            do {
                try R.validate()
            } catch {
                // смело вызываем fatalError и передаем туда текст ошибки
                // так как этот код вызывается только в debug режиме то делать это можно не опасаясь
                // если что-то пойдет не так, то данный код отловится на этапе тестирования и ни в коем случае не должен попасть в production
                fatalError(error.localizedDescription)
            }
            #endif
    
            return true
        }
    }

    И вот вы уже готовы купить два!


    Shut up and take my money!

    Как внедрять?


    *Component-based system — wiki, концепция разработки кода, при которой компоненты (набор экранов/модулей связанных между собой) разрабатываются в замкнутой среде (в нашем случае в локальных подах) с целью уменьшения связанности кодовой базы. Многим известен подход в backend, который основан на данном концепте — микросервисы.


    *Монолит — wiki, концепция разработки кода, при которой вся кодовая база лежит в одном репозитории, и код тесно связан между собой. Данная концепция подходит маленьким проектам с конечным набором функций.


    Если вы разрабатываете монолитное приложение или используете только сторонние зависимости, то вам повезло (но это не точно). Берете tutorial и выполняете все строго по нему.


    Это был не наш случай. Мы втянулись. Так как мы используем component-based system, то, помимо встраивания R.swift в основное приложение, мы решили встраивать его еще и локальные поды (которые являются компонентами).


    Из-за постоянного обновления локализаций, картинок и всех элементов, которые влияют на файл R.generated.swift, возникает много конфликтов в сгенерируемом файле при мерже в общую ветку. И чтобы этого избежать, следует убрать R.generated.swift из под власти git репозитория. Автор так же рекомендует это делать.


    Добавляем в .gitignore следующие строки.


    # R.Swift generated files
    *.generated.swift

    Еще, если вы не хотите генерировать код для каких-то ресурсов, всегда можно воспользоваться игнорированием отдельных файлов или целых папок:


    "${PODS_ROOT}/R.swift/rswift" generate "${SRCROOT}/Example" "--rswiftignore" "Example/.rswiftignore"

    описание .rswiftignore


    Как и в основном проекте, нам было важно не добавлять R.generated.swift файлы из локальных подов в git репозиторий. Мы начали рассматривать варианты, как это можно было бы сделать:


    • alias на R.generated.swift, чтобы файл (alias, например: R.swift) добавился в проект, а затем, при компиляции по ссылке был доступен настоящий файл. Но cocoapods умный, и не позволил так сделать
    • в podspec в pre-compile фазе добавлять файл R.generated.swift в сам проект с помощью скриптов, но тогда он добавится просто, как файл в файловой системе, а в проекте файл так и не появится
    • другие более-менее аккуратные варианты
    • магия в Podfile


      Магия
      Magic

      pre_install do |installer|
          installer.pod_targets.flat_map do |pod_target|
              if pod_target.pod_target_srcroot.include? 'LocalPods' # Идем по всем подам и если в их пути есть LocalPods, то применяем к ним то, что ниже
                  pod_target_srcroot = pod_target.pod_target_srcroot # Достаем путь
                  pod_target_path = pod_target_srcroot.sub('${PODS_ROOT}/..', '.') # Меняем переменные окружения на относительный путь
                  pod_target_sources_path = pod_target_path + '/' + pod_target.name + '/Sources' # Создаем путь до папки Sources
                  generated_file_path = pod_target_sources_path + '/R.generated.swift' # Создаем путь до файла R.generated.swift
                  File.new(generated_file_path, 'w') # Создаем пустой файл R.generated.swift с возможностью записи в него
              end
          end
      end



    • и еще вариант… все же добавить R.generated.swift в git

    Мы временно остановились на варианте: "магия в Podfile", при том, что у него был ряд недостатков:


    • Запускать его можно было только из корня проекта (хотя cocoapods может запускаться почти из любой папки в проекте)
    • У всех подов должна быть папка с названием Sources (хотя это не критично, если в подах порядок)
    • Он был странный и непонятный, а поддерживать рано или поздно пришлось бы (это все же костыль)
    • Если какая-то сторонняя библиотека лежит в папке, в пути которой есть "LocalPods", то он попытается и туда добавить R.generated.swift файл или упадет с ошибкой

    prepare_command


    Живя какое-то время со скриптом и страдая, я решил поизучать эту тему шире и нашел еще один вариант.
    В Podspec есть prepare_command, которая предназначена, как раз для создания и изменения исходников, которые затем будут добавлены в проект.


    *News — название пода, которое нужно заменить на название именно вашего локального пода
    *touch — команда для создания файла. Аргументом является относительный путь до файла (включая название файла с расширением)


    Далее мы будем производить махинации с News.podspec


    Данный скрипт вызывается при первом запуске pod install и добавляет нужный нам файл в папку исходников в поде.


    Pod::Spec.new do |s|
    
        # ...
    
        generated_file_path = "News/Sources/R.generated.swift"
        s.prepare_command = 
        <<-CMD
            touch "#{generated_file_path}"
        CMD
    
        # ...
    
    end

    Далее идет еще один "финт ушами" — нам нужно сделать вызов скрипта R.swift для локальных подов.


    Pod::Spec.new do |s|
    
        # ...
    
        s.dependency 'R.swift'
    
        r_swift_script = '"${PODS_ROOT}/R.swift/rswift" generate "${PODS_TARGET_SRCROOT}/News/Sources"'
        s.script_phases = [
            {
                :name => 'R.swift',
                :script => r_swift_script, 
                :execution_position => :before_compile
            }
        ]
    end

    Правда, есть одно "но". C локальными подами prepare_command не работает, вернее работает, но в каких-то особых случаях. Есть обсуждение этой темы на Github.


    Fatality


    *Fatality — wiki, финальный удар в Mortal Kombat.


    Проведя еще немножечко ресерча, я нашел еще одно решение — гибрид подходов c prepare_command и pre_install.


    Небольшая модификация магии из Podfile:


    pre_install do |installer|
        # map development pods
        installer.development_pod_targets.each do |target|
            # get only main spec and exclude subspecs
            spec = target.non_test_specs.first
            # get full podspec file path
            podspec_file_path = spec.defined_in_file
            # get podspec dir path
            pod_directory = podspec_file_path.parent
    
            # check if path contains local pods directory
            # exclude development but non local pods
            local_pods_directory_name = "LocalPods"
            if pod_directory.to_s.include? local_pods_directory_name
                # go to pod root directorty and run prepare command in sub-shell
                system("cd \"#{pod_directory}\"; #{spec.prepare_command}")
            end
        end
    end

    И тот же самый скрипт, что не запускался для локальных подов


    Pod::Spec.new do |s|
    
        # ...
    
        s.dependency 'R.swift'
    
        generated_file_path = "News/Sources/R.generated.swift"
        s.prepare_command = 
        <<-CMD
            touch "#{generated_file_path}"
        CMD
    
        r_swift_script = '"${PODS_ROOT}/R.swift/rswift" generate "${PODS_TARGET_SRCROOT}/News/Sources"'
        s.script_phases = [
            {
                :name => 'R.swift',
                :script => r_swift_script, 
                :execution_position => :before_compile
            }
        ]
    end

    В итоге это работает так, как мы и ожидаем.


    Наконец-то!


    P.S.:


    Пытался сделать еще кастомную команду вместо prepare_command, но pod lib lint (команда для валидации контента podspec и самого пода) ругается на лишние переменные и не проходит.


    Нелокальные поды


    В удаленных подах (те, что находятся каждый в своем репозитории) не нужно всей этой скриптовой магии, что описано выше, ибо там кодовая база строго привязана к версии зависимости.


    Достаточно просто встроить в сам Example (проект, генерируемый после команды pod lib create <Name>) R.swift скрипт и добавлять R.generated.swift в пакет с библиотекой (подом). Если в проекте нет Example, то уже придется писать скрипты, которые будут похожи на те, которые я привел.


    P.S.:


    Есть небольшое уточнение:
    R.swift + Xcode 10 + new build system + incremental build != <3
    Подробнее о проблеме на главной странице библиотеки или тут
    R.swift v4.0.0 не работает с cocoapods 1.6.0 :(
    Думаю в скором времени уже поправят все проблемы.


    Вывод


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


    При этом не нужно перегружать тестирование и находить баги как можно раньше. В нашем случае это находится либо в момент компиляции кода разработчиком, либо на прогоне проверок для Pull Requests. Тем самым, отсутствие локализации мы находим не внимательным взглядом тестировщиков или автоматизированными тестами, а обычным процессом сборки приложения.


    Также нужно учитывать тот факт, что это — сторонний инструмент, который завязан на структуру проекта и парсит ее контент. Если поменяется структура файла проекта, то и инструмент придется менять.
    Мы пошли на этот риск и, в случае чего, всегда готовы поменять данный инструмент на любой другой или написать свой.


    А выигрыш от R.swift — огромное количество человеко-часов, которые команда может потратить на куда более важные вещи: новые фичи, ресерч новых технический решений, повышение качества и так далее. R.swift сполна вернул то количество времени, которое было потрачено на его интеграцию, даже с учетом возможной замены его в будущем на другое похожее решение.


    R.swift


    Bonus


    Вы можете поиграться с примером, чтобы сразу своими глазами увидеть профит от кодогенерации для ресурсов. Исходный код проекта "на поиграться": GitHub.


    Спасибо огромное, что прочитали статью или же просто долистали до этого места, мне в любом случае приятно)


    That's all.

    • +15
    • 2,2k
    • 7

    Tinkoff.ru

    193,00

    IT’s Tinkoff.ru — просто о сложном

    Поделиться публикацией

    Похожие публикации

    Комментарии 7
      0
      Да, либа отличная, пользуюсь уже во втором проекте и доволен, как слон. Она реально экономит тонну времени и нервов.
        0
        Тоже постоянно пользуемся. Очень не хватало разделения локализации по подструктурам (не все онлайн-локализаторы умеют выгружать несколько *.strings файлов) — вот немножко поконтрибьюитили туда, может кому пригодится github.com/mac-cain13/R.swift/pull/467
          +1

          Мы для своих проектов выбрали SwiftGen из-за поддержки шаблонов, и в целом более гибкого поведения.
          Шаблоны удобны тем, что можно для каких-то необычных сценариев описать то поведение, которое тебе нужно. Например, в одном из проектов различные конфигурационные строки (вроде base URL-ов) хранились в .strings-файлах, и по ним swiftgen создавал уже типизированные константы. Для этого пришлось поправить шаблон, чтобы имена сгенерированных enum-ов не конфликтовали между собой.

            0
            По тем же причинам остановились на SwiftGen и, не так давно, тоже писали как мы его настроили в наших проектах
            habr.com/company/hh/blog/423381
            0
            А можно где-нибудь подробнее почитать про разбитие на модули через локальные поды? Сейчас как раз занимаемся вопросом распиливания монолита на меньшие куски.
              0

              В разделе “Bonus” можно посмотреть пример, который показывает, как пользоваться локальными подами для создания component-based system. Если необходимы какие-то дополнительные подробности, то можно пообщаться в twitter :)

                0
                Хм. В бонусе очень простой пример. Меня конкретно интересует более тяжелый случай, когда проект зависит от локального пода А, который зависит от локального Б и все они зависят от внешнего пода С. К сожалению подспек неподдерживает зависимость от локальных подов и это нам все ломает.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое