The New iOS Mobile Enterprise. Part # 1: Resource Code Generation

    Hello!


    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 .


    Go!


    Description of the problem


    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.


    Localization bug

    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:


    • The application crashes, because you incorrectly specified the name of the picture and made force unwrap
      UIImage(named: "NotExist")!
    • The application crashes if the storyboard is not added to the target.
    • The application crashes if you have created a controller from the storyboard with a nonexistent ID.
    • The application crashes if you created a controller from the storyboard with an existing ID, but did a cast to the wrong class.
    • Unpredictable behavior if you use a font in code that is not added to info.plist, or the font file is not set to target: a crash is possible, and maybe just getting a standard font instead of the one you need. Apple Developer: Custom Fonts , Stackoverflow: crash
    • An application crashes if storyboards have specified a class in the controller that does not exist.
    • A bunch of monotonous code in which icons, fonts, controllers, views are created
    • There are no pictures, icons in runtime, although the name of the picture is in the storyboard, but not in the assets
    • The storyboard uses a font that is not in the info.plist
    • ID strings appear in the application, instead of being localized in unexpected places, due to deletion of strings in Localizable.strings (they thought they were not used)
    • Something else I forgot to mention, or we have not yet come across.

    Cause → Consequence


    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.


    Finding a solution


    We wondered how such problems are solved at all, and how we can fix it. I remembered one of the conferences in Cocoaheads 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.


    How does R.swift work


    It runs the pre-compile build phase script, runs through the project structure and generates a file under the name R.generated.swiftthat needs 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.StringResourceyou need ?" - You ask. I myself do not understand why to generate it, but, as the authors explain, it is needed for the following: link .


    Application in real conditions


    A little explanation of the content below:


    * It was - they used the approach for a while, as a result, left it
    * It became - the approach that we use when writing new code
    * It wasn’t, but you may have - an approach that never existed in our application, but I met it in various projects, in those days, when he had not worked at Tinkoff.ru.


    Localization


    We started to use R.swiftfor 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.


    Warning

    It was not, but you can have:
    final class NewsViewController: UIViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
            titleLabel.text = NSLocalizedString("news_title", comment: "News title")
        }
    }

    It was:
    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
        }
    }

    It became:
    titleLabel.text = R.string.localizable.newsTitle()

    Images


    Now, if we renamed something to * .xcassets, and did not change it in the code, then the project simply will not build.


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

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

    Storyboards


    It was:
    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() ¯\_(ツ)_/¯}

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

    And so on.


    Validation Storyboard


    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:


    • Indicated the name of the picture, which is not in the project
    • Indicated the font, and then stopped using it and removed it from the project (from info.plist)

    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
        }
    }

    And now you are ready to buy two!


    Shut up and take my money!

    How to implement?


    * 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 .


    We add in the .gitignorefollowing lines.


    # 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"

    description .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:


    • alias on R.generated.swift, so that the file (alias, for example: R.swift) is added to the project, and then, when the link is compiled, the real file is available. But cocoapods is clever, and did not allow to do so
    • in the podspec in the pre-compile phase, add the R.generated.swift file to the project itself using scripts, but then it will be added simply as a file in the file system, and the file will not appear in the project
    • other more or less accurate options
    • magic in podfile


      Magic
      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 с возможностью записи в негоendendend



    • and another option ... yet add R.generated.swift to git

    We temporarily stopped at the option: “magic in the Podfile”, despite the fact that it had a number of drawbacks:


    • It could only be started from the project root (although cocoapods can be run from almost any folder in the project)
    • All pods should have a folder called Sources (although this is not critical if the pods have order)
    • It was strange and incomprehensible, but I would have to support sooner or later (it is still a crutch)
    • If some third-party library is in a folder with "LocalPods" in its path, then it will try to add the R.generated.swift file there or it will fall with an error

    prepare_command


    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 pod that needs to be replaced with the name of your local pod.
    * 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 at the first launch pod installand 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." It prepare_commanddoes not work with local sub-markets , or rather, it works, but in some special cases. There is a discussion of this topic on Github .


    Fatality


    * Fatality - wiki , the final blow to Mortal Kombat.


    After a little more research, I found another solution - a hybrid of approaches c prepare_commandand 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}")
            endendend

    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 prepare_command, but pod lib lint(the command for validating the content of the podspec and the pod itself) swears at the extra variables and does not work.


    Non-local pods


    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
    For more information about the problem on the library’s main page or here
    R.swift v4.0.0 does not work with cocoapods 1.6.0 :(
    I think soon correct all the problems.


    Conclusion


    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.


    R.swift


    Bonus


    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 .


    Thank you so much for reading the article or just adding to this place, in any case I am pleased)


    That's all.


    Also popular now: