- PVSM.RU - https://www.pvsm.ru -
Меня зовут Дмитрий. Так получилось, что я являюсь тим лидом в команде из 13 iOS разработчиков уже на протяжении двух лет. И вместе мы трудимся над приложением Тинькофф Бизнес [1].
Хочу поделиться с вами нашим опытом о том, как релизить приложение в неожиданный момент с максимальным набором фич или баг фиксами и при этом не поседеть.
Расскажу о практиках и подходах которые помогли команде заметно ускориться в разработке и тестировании и заметно сократить количество стресса, багов, проблем при внеплановом или срочном релизе. #MakeReleaseWithoutStress [2].
Представьте себе следующую ситуацию.
Идет очередной релиз. Ему предшествовало регрессионное тестирование, тестировщики снова нашли место, в котором вместо текста в приложении отображается ID строки.

Это была одна из самых частых наших проблем с которой мы сталкивались.
Вы можете не столкнуться с данной проблемой, если у вас не локализовано приложение на другой язык, или вся локализация пишется строками прямо в коде без использования Localizable.strings файла.
Но вы можете столкнуться с другими проблемами, которые мы поможем вам решить:
UIImage(named: "NotExist")!
Почему это все происходит?
Есть программный код, который компилируется. Если вы что-то написали не так (синтаксически, или неправильное название функции при вызове), то ваш проект просто не соберется. Это понятно, очевидно и логично.
А как быть с такими вещами, как ресурсы?
Они не компилируются, просто добавляются в bundle уже после компиляции кода. В связи с этим может возникать большое количество проблем в runtime, например, тот случай, что описан выше — со строками в локализации.
Мы задумались, как подобные проблемы решаются вообще, и как мы можем это исправить. Я вспомнил одну из конференций Cocoaheads в mail.ru [5]. Там был доклад про сравнение инструментов для кодогенерации.
Посмотрев еще раз, что эти инструменты (библиотеки/фреймворки) из себя представляют, мы наконец-то нашли то, что было нужно.
При этом, похожий подход уже годами используется разработчиками под Android. Google подумал о них и сделал им такой инструмент из коробки. А ведь нам Apple даже стабильный Xcode не может сделать...
Оставалось выяснить только одно — какой именно инструмент выбрать: Natalie [6], SwiftGen [7] или R.swift [8]?
У Natalie не было поддержки локализации, от него было решено сразу отказаться. У SwiftGen и R.swift были очень похожие возможности. Мы сделали выбор в пользу R.swift, просто исходя из количества звезд, зная о том, что в любой момент мы можем поменять на SwiftGen.
Запускается 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?", — спросите вы. Я сам не понимаю, зачем его генерировать, но, как объясняют авторы, то нужен он для следующего: ссылка [9].
Небольшое пояснение контента ниже:
*Было — пользовались подходом какое-то время, в итоге, ушли от него
*Стало — подход который используем при написании нового кода
*Не было, но у вас может быть — подход, которого никогда не существовало в нашем приложении, но я встречал его в различных проектах, в те далекие времена, когда еще не работал в Tinkoff.ru.
Мы начали применять R.swift для локализации, это избавило нас от проблем, о которых мы писали в самом начале. Теперь, если поменялся id в локализации, то проект не собирется.
*Это работает только при условии, если вы поменяли id во всех локализациях на другой. Если же в какой-то из локализаций осталась строка, то при компиляции будет warning, что данный id локализован не на всех языках.

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()
Теперь, если мы что-то переименовали в *.xcassets, и не поменяли в коде, то проект просто не соберется.
imageView.image = UIImage(named: "NotExist") // иконка не видна пользователям
imageView.image = UIImage(named: "NotExist")! // crash
imageView.image = #imageLiteral(resourceName: "NotExist") // crash
imageView.image = R.image.tinkoffLogo() // иконка всегда видна пользователям
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() ¯_(ツ)_/¯
}
И так далее.
R.validate() [10] — это замечательный инструмент, который бьет по рукам (вернее просто выкидывает error в catch блок), если вы сделали что-то не так в storyboard или xib файлах.
Например:
Использование:
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
}
}

*Component-based system — wiki [11], концепция разработки кода, при которой компоненты (набор экранов/модулей связанных между собой) разрабатываются в замкнутой среде (в нашем случае в локальных подах) с целью уменьшения связанности кодовой базы. Многим известен подход в backend, который основан на данном концепте — микросервисы.
*Монолит — wiki [12], концепция разработки кода, при которой вся кодовая база лежит в одном репозитории, и код тесно связан между собой. Данная концепция подходит маленьким проектам с конечным набором функций.
Если вы разрабатываете монолитное приложение или используете только сторонние зависимости, то вам повезло (но это не точно). Берете tutorial [13] и выполняете все строго по нему.
Это был не наш случай. Мы втянулись. Так как мы используем component-based system, то, помимо встраивания R.swift в основное приложение, мы решили встраивать его еще и локальные поды (которые являются компонентами).
Из-за постоянного обновления локализаций, картинок и всех элементов, которые влияют на файл R.generated.swift, возникает много конфликтов в сгенерируемом файле при мерже в общую ветку. И чтобы этого избежать, следует убрать R.generated.swift из под власти git репозитория. Автор так же рекомендует это делать [14].
Добавляем в .gitignore следующие строки.
# R.Swift generated files
*.generated.swift
Еще, если вы не хотите генерировать код для каких-то ресурсов, всегда можно воспользоваться игнорированием отдельных файлов или целых папок:
"${PODS_ROOT}/R.swift/rswift" generate "${SRCROOT}/Example" "--rswiftignore" "Example/.rswiftignore"
Как и в основном проекте, нам было важно не добавлять R.generated.swift файлы из локальных подов в git репозиторий. Мы начали рассматривать варианты, как это можно было бы сделать:
магия в Podfile
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
Мы временно остановились на варианте: "магия в Podfile", при том, что у него был ряд недостатков:
Живя какое-то время со скриптом и страдая, я решил поизучать эту тему шире и нашел еще один вариант.
В Podspec есть prepare_command [16], которая предназначена, как раз для создания и изменения исходников, которые затем будут добавлены в проект.
*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 [17].
*Fatality — wiki [18], финальный удар в 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
Подробнее о проблеме на главной странице библиотеки [19] или тут [20]
R.swift v4.0.0 не работает с cocoapods 1.6.0 :(
Думаю в скором времени уже поправят все проблемы.
Всегда нужно держать планку качества как можно выше. Особенно это важно для приложений, которые работают с финансами.
При этом не нужно перегружать тестирование и находить баги как можно раньше. В нашем случае это находится либо в момент компиляции кода разработчиком, либо на прогоне проверок для Pull Requests. Тем самым, отсутствие локализации мы находим не внимательным взглядом тестировщиков или автоматизированными тестами, а обычным процессом сборки приложения.
Также нужно учитывать тот факт, что это — сторонний инструмент, который завязан на структуру проекта и парсит ее контент. Если поменяется структура файла проекта, то и инструмент придется менять.
Мы пошли на этот риск и, в случае чего, всегда готовы поменять данный инструмент на любой другой или написать свой.
А выигрыш от R.swift — огромное количество человеко-часов, которые команда может потратить на куда более важные вещи: новые фичи, ресерч новых технический решений, повышение качества и так далее. R.swift сполна вернул то количество времени, которое было потрачено на его интеграцию, даже с учетом возможной замены его в будущем на другое похожее решение.
R.swift [8]
Вы можете поиграться с примером, чтобы сразу своими глазами увидеть профит от кодогенерации для ресурсов. Исходный код проекта "на поиграться": GitHub [21].
Автор: coolerov
Источник [22]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ios-development/301345
Ссылки в тексте:
[1] Тинькофф Бизнес: https://itunes.apple.com/ru/app/%D1%82%D0%B8%D0%BD%D1%8C%D0%BA%D0%BE%D1%84%D1%84-%D0%B1%D0%B8%D0%B7%D0%BD%D0%B5%D1%81/id1031837973?mt=8
[2] #MakeReleaseWithoutStress: #MakeReleaseWithoutStress
[3] Developer Apple: Custom Fonts: https://developer.apple.com/documentation/uikit/text_display_and_fonts/adding_a_custom_font_to_your_app
[4] Stackoverflow: crash: https://stackoverflow.com/questions/26248493/strange-crash-with-font-added-to-info-plist
[5] одну из конференций Cocoaheads в mail.ru: https://youtu.be/AjJhPwEUZSI?t=551
[6] Natalie: https://github.com/krzyzanowskim/Natalie
[7] SwiftGen: https://github.com/SwiftGen/SwiftGen
[8] R.swift: https://github.com/mac-cain13/R.swift
[9] ссылка: https://github.com/mac-cain13/R.swift/pull/235#issuecomment-238140812
[10] R.validate(): https://github.com/mac-cain13/R.swift/blob/master/Documentation/Examples.md#runtime-validation
[11] wiki: https://en.wikipedia.org/wiki/Component-based_software_engineering
[12] wiki: https://en.wikipedia.org/wiki/Monolithic_application
[13] tutorial: https://github.com/mac-cain13/R.swift#installation
[14] рекомендует это делать: https://github.com/mac-cain13/R.swift#cocoapods-recommended
[15] описание .rswiftignore: https://github.com/mac-cain13/R.swift/blob/master/Documentation/Ignoring.md#ignoring-resources
[16] prepare_command: https://guides.cocoapods.org/syntax/podspec.html#prepare_command
[17] обсуждение этой темы на Github: https://github.com/CocoaPods/CocoaPods/issues/2187
[18] wiki: https://en.wikipedia.org/wiki/Fatality_(Mortal_Kombat)
[19] Подробнее о проблеме на главной странице библиотеки: https://github.com/mac-cain13/R.swift#%EF%B8%8F-rswift-and-xcode-10--swift-42
[20] тут: https://github.com/mac-cain13/R.swift/issues/456
[21] GitHub: https://github.com/cooler333/code-gen-article-example
[22] Источник: https://habr.com/post/431148/?utm_campaign=431148
Нажмите здесь для печати.