My name is Dmitry. It so happened that I am a team leader in a team of 13 iOS developers for the past two years. And together we work on the Tinkoff Business app.
I want to share with you our experience on how to release the application at an unexpected moment with the maximum set of features or bug fixes and not get gray.
I’ll tell you about the practices and approaches that helped the team to speed up significantly in development and testing and noticeably reduce the amount of stress, bugs, problems in an unplanned or urgent release. #MakeReleaseWithoutStress .
Imagine the following situation.
There is another release. It was preceded by regression testing, testers again found a place in which, instead of text, the application displays the line ID.
This was one of our most frequent problems we faced.
You may not encounter this problem if you do not have an application localized in another language, or all localization is written in strings directly in the code without using the Localizable.strings file.
But you may encounter other problems that we will help you solve:
UIImage(named: "NotExist")!
Why is this all happening?
There is a program code that is compiled. If you have written something wrong (syntactically, or the wrong name of the function when you call), then your project simply does not collect. This is understandable, obvious and logical.
And what about things like resources?
They are not compiled, they are simply added to the bundle after the code has been compiled. In this regard, there may be a large number of problems at runtime, for example, the case that is described above - with lines in localization.
We wondered how such problems are solved at all, and how we can fix it. I remembered one of the Cocoaheads conferences at mail.ru. There was a report about comparing tools for code generation.
Having looked again that these tools (libraries / frameworks) are, we finally found what was needed.
At the same time, a similar approach has been used by Android developers for years. Google thought about them and made it such a tool out of the box. But for us Apple, even a stable Xcode can not do ...
It only remained to find out one thing - which tool to choose: Natalie , SwiftGen or R.swift ?
Natalie did not have localization support, it was decided to immediately abandon him. SwiftGen and R.swift had very similar capabilities. We chose R.swift, simply based on the number of stars, knowing that at any moment we can change to SwiftGen.
It runs the pre-compile build phase script, runs through the project structure and generates a file called R.generated.swift
, which will need to be added to the project (we will tell you in more detail how to do this at the very end).
The file has the following structure:
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: "") } } } }
Using:
let str = R.string.localizable.card_actions_activate_apple_pay() print(str) > Apple Pay
“Why do Rswift.StringResource
need Rswift.StringResource
?” - you ask. I myself do not understand why to generate it, but, as the authors explain, it is needed for the following: link .
A little explanation of the content below:
* It was - used the approach for a while, in the end, left him
* Became - the approach that we use when writing new code
* It wasn’t, but you can have it - an approach that never existed in our application, but I met it in various projects, in those days when I hadn’t worked at Tinkoff.ru.
We began to use R.swift
for localization, it saved us from the problems that we wrote about at the very beginning. Now, if the id has changed in localization, then the project will not build.
* This only works if you change the id in all localizations to another. If a string is left in one of the localizations, then during the compilation there will be a warning that the given id is not localized in all languages.
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()
Now, if we renamed something to * .xcassets, and did not change it in the code, then the project simply will not build.
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 (eg: "somestoryboard") - get nil or crash in else let someVCIdentifier = "SomeViewController" // Change to something else (eg: "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() ¯\_(ツ)_/¯ }
And so on.
R.validate () is a great tool that hits hands (or rather, simply throws an error into a catch block) if you did something wrong in a storyboard or xib files.
For example:
Using:
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 - a wiki , the concept of developing code, in which components (a set of screens / modules interconnected) are developed in a closed environment (in our case, in local sub-fields) in order to reduce the connectedness of the code base. Many people know the approach in the backend, which is based on this concept - microservices.
* Monolith - wiki , the concept of developing code, in which the entire code base lies in a single repository, and the code is closely linked. This concept is suitable for small projects with a finite set of functions.
If you are developing a monolithic application or using only third-party dependencies, then you are lucky (but this is not accurate). Take the tutorial and do everything strictly according to it.
This was not our case. We got involved. Since we use the component-based system, then, embedding R.swift in the main application, we decided to embed it also in local subsets (which are components).
Due to the constant updating of localizations, images and all elements that affect the R.generated.swift file, there are many conflicts in the generated file when merge to the common branch. And to avoid this, R.generated.swift should be removed from the git repository. The author also recommends doing this .
Add the following lines to .gitignore
.
# R.Swift generated files *.generated.swift
Still, if you do not want to generate code for some resources, you can always use ignoring individual files or entire folders:
"${PODS_ROOT}/R.swift/rswift" generate "${SRCROOT}/Example" "--rswiftignore" "Example/.rswiftignore"
As in the main project, it was important for us not to add the R.generated.swift files from the local pods to the git repository. We began to consider options for how this could be done:
magic in 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
We temporarily stopped at the option: “magic in the Podfile”, despite the fact that it had a number of drawbacks:
Living for some time with the script and suffering, I decided to study this topic more widely and found another option.
Podspec has prepare_command , which is designed to create and modify source codes, which will then be added to the project.
* News - the name of the hearth, which must be replaced with the name of your local hearth.
* touch - the command to create the file. The argument is a relative path to the file (including the name of the file with the extension)
Next, we will produce frauds with News.podspec
This script is called when pod install
first run and adds the file we need to the source folder in the pod.
Pod::Spec.new do |s| # ... generated_file_path = "News/Sources/R.generated.swift" s.prepare_command = <<-CMD touch "#{generated_file_path}" CMD # ... end
Next is another "feint ears" - we need to make a call to the script R.swift for local podov.
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
True, there is one "but." With local sub-paths, prepare_command
does not work, or rather works, but in some special cases. There is a discussion of this topic on Github .
* Fatality - wiki , the final blow to Mortal Kombat.
After a little more research, I found another solution, a hybrid of the prepare_command
and pre_install
.
A small modification of the magic from the 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
And the same script that did not run for local podov
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
In the end, this works as we expect.
Finally!
PS:
I tried to make another custom command instead of prepare_command
, but pod lib lint
(the command to validate the content of the podspec and the submission itself) swears at the extra variables and does not work.
In remote sub-sites (those that are each in their repository), all this script-based magic is not needed, as described above, because there the code base is strictly tied to the dependency version.
It is enough just to embed Example itself (project generated after the pod lib create <Name> command) R.swift script and add R.generated.swift to the package with the library (pod). If there is no Example in the project, then you will have to write scripts that will be similar to the ones I brought.
PS:
There is a small clarification:
R.swift + Xcode 10 + new build system + incremental build! = <3
Read more about the problem on the main page of the library or here.
R.swift v4.0.0 does not work with cocoapods 1.6.0 :(
I think soon all the problems will be corrected.
You should always keep the quality bar as high as possible. This is especially important for applications that work with finance.
You do not need to overload testing and find bugs as early as possible. In our case, this is either at the moment of compilation of the code by the developer, or on the test run for Pull Requests. Thus, we find the lack of localization not by the attentive eyes of testers or automated tests, but by the usual process of building an application.
You also need to take into account the fact that this is a third-party tool that is tied to the structure of the project and parses its content. If the structure of the project file changes, then the tool will have to be changed.
We took this risk and, in which case, we are always ready to change this tool to any other one or write our own.
And the gain from R.swift is a huge amount of man-hours that a team can spend on much more important things: new features, new technical solutions, quality improvement, and so on. R.swift fully returned the amount of time spent on its integration, even taking into account its possible replacement in the future with another similar solution.
You can play around with an example to immediately see with your own eyes the profit from code generation for resources. Source code of the project "to play": GitHub .
Source: https://habr.com/ru/post/431148/
All Articles