新的iOS移动企业版。 第1部分:资源代码生成

大家好!


我叫德米特里。 碰巧的是,在过去两年中,我是由13个iOS开发人员组成的团队的负责人。 我们一起致力于Tinkoff Business应用程序。


我想与您分享我们的经验,即如何在不期望的时候以最大的功能集或错误修复集发布应用程序,并且仍然不会灰显。


我将向您介绍一些实践和方法,这些方法和方法可帮助团队显着加快开发和测试速度,并显着减少计划外或紧急发布带来的压力,错误和问题。 #MakeReleaseWithoutStress


走吧


问题描述


想象以下情况。


还有另一个版本。 在进行回归测试之前,测试人员再次找到了显示行ID的位置,而不是应用程序中的文本。


本地化错误

这是我们遇到的最常见的问题之一。


如果您没有将应用程序本地化为另一种语言,或者没有使用Localizable.strings文件,所有本地化都直接在代码行中编写,则可能不会遇到此问题。


但是您可能还会遇到其他问题,我们将为您解决:


  • 应用程序崩溃是因为您错误地指定了图片名称并强行打开了包装
    UIImage(named: "NotExist")! 
  • 如果未将情节提要添加到目标,则应用程序崩溃
  • 如果您从故事板创建了一个不存在ID的控制器,则应用程序将崩溃
  • 如果您从具有现有ID的情节提要中创建控制器,但转换为错误的类,则应用程序将崩溃
  • 如果您在未添加到info.plist的代码中使用一种字体,或者如果该字体文件未标记有target:可能会导致无法预料的行为:崩溃,或者有可能只是获得一种标准字体而不是所需的字体。 开发人员Apple:自定义字体Stackoverflow:崩溃
  • 如果情节提要向控制器指示不存在的类,则应用程序将崩溃
  • 一堆用于创建图标,字体,控制器,视图的单调代码
  • 尽管图片的名称在情节提要中,但资产中没有,但运行时中没有图片,图标
  • 情节提要使用的字体不在info.plist中
  • 由于删除了Localizable.strings中的行(未使用行),因此行ID出现在应用程序中,而不是被本地化在意外的位置
  • 我忘记提及的其他内容,或者我们尚未遇到。

原因→效果


为什么这一切都发生了?


有可以编译的程序代码。 如果您写错了一些东西(语法上或调用时函数名不正确),则您的项目将根本无法汇编。 这是可以理解,显而易见和合乎逻辑的。


但是资源之类的东西呢?


它们没有被编译,只是在编译代码后将它们添加到包中。 在这方面,在运行时可能会发生大量问题,例如,上述情况-字符串本地化。


寻找解决方案


我们考虑了一般如何解决此类问题以及如何解决此问题。 我回想起mail.ru上的一次Cocoaheads会议。 有人谈论比较代码生成工具。


在再次看到这些工具(库/框架)的全部含义之后,我们终于找到了所需的工具。


同时,多年来,Android的开发人员一直在使用类似的方法。 Google对它们进行了思考,使它们成为开箱即用的工具。 但是,即使是稳定的Xcode,苹果也无法做到...


剩下的就是只找出要选择的乐器: NatalieSwiftGenR.swift


Natalie没有本地化支持,因此决定立即放弃。 SwiftGen和R.swift具有非常相似的功能。 我们选择R.swift只是根据星星数,它知道我们随时可以更改为SwiftGen。


R.swift如何工作


启动预编译的构建阶段脚本,该脚本在项目结构中运行并生成一个名为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需要Rswift.StringResource ?”。 我自己不明白为什么要生成它,但是,正如作者解释的那样,以下内容是必需的: link


实际应用


以下内容的简要说明:


*是-他们使用该方法一段时间后,最终离开了
*它已成为-编写新代码时使用的方法
*并非如此,但是您可以拥有-一种在我们的应用程序中从未存在过的方法,但是当我那时还没有在Tinkoff.ru工作时,我在各种项目中都遇到了它。


本地化


我们开始使用R.swift进行本地化,这使我们摆脱了最初写的问题。 现在,如果本地化中的id已更改,则将不会组装项目。


*仅在将所有本地化的ID更改为另一个ID时,此方法才有效。 如果字符串保留在任何本地化版本中,则在编译时会出现一个警告,提示此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 (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() ¯\_(ツ)_/¯ } 

依此类推。


验证情节提要


如果您在情节提要或xib文件中做错了什么, R.validate()是一个很棒的工具,可以动动手(或者,只是将错误抛出到catch块中)。
例如:


  • 指示图片名称,该名称不在项目中
  • 他们指出了字体,然后停止使用并将其从项目中删除(来自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 } } 

现在您准备购买两个!


闭嘴拿我的钱!

如何实施?


*基于组件的系统-Wiki ,一种代码开发概念,其中,组件(一组连接在一起的屏幕/模块)在封闭的环境中(在我们的情况下是在本地pod中)开发,以减少代码库的一致性。 许多人都知道后端的方法,该方法基于此概念-微服务。


* Monolith- Wiki ,即代码开发的概念,其中整个代码库都位于一个存储库中,并且代码紧密相关。 此概念适用于功能有限的小型项目。


如果您正在开发单一应用程序或仅使用第三方依赖项,那么您很幸运(但这并不准确)。 接受本教程并严格执行所有操作。


这不是我们的情况。 我们参与其中。 因为我们使用基于组件的系统,所以将R.swift嵌入主应用程序之外,我们还决定将其嵌入本地pod(即组件)中。


由于本地化,图片和影响R.Generated.swift文件的所有元素的不断更新,合并到common分支时,生成的文件中存在许多冲突。 为了避免这种情况,您应该从git存储库中删除R.Generated.swift。 作者还建议这样做


.gitignore添加到.gitignore


 # R.Swift generated files *.generated.swift 

另外,如果您不想为某些资源生成代码,则可以始终使用忽略单个文件或整个文件夹的方法:


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

.rswiftignore的描述


与在主项目中一样,对我们来说重要的是不要将R.Generated.swift文件从本地Pod添加到git存储库。 我们开始考虑如何实现此目的的选项:


  • R.Generated.swift上的别名,以便将文件(别名,例如:R.swift)添加到项目中,然后在通过引用进行编译时,可以使用真实文件。 但是可可足类很聪明,不被允许这样做
  • 在预编译阶段的podspec中,使用脚本将R.Generated.swift文件添加到项目本身中,但是之后它将简单地作为文件添加到文件系统中,并且该文件不会出现在项目中
  • 其他或多或少的整洁选择
  • 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 



  • 和另一个选项...仍然将R.Generated.swift添加到git

我们暂时选择了以下选项:“ Podfile中的魔术”,尽管它存在许多缺点:


  • 它只能从项目根目录启动(尽管cocoapods可以从项目中的几乎任何文件夹启动)
  • 所有Pod都应有一个名为Sources的文件夹(尽管如果Pod顺序正确,这并不重要)
  • 他很陌生,难以理解,但迟早他得支持(这仍然是拐杖)
  • 如果某个第三方库位于其路径中带有“ LocalPods”的文件夹中,则它将尝试在该文件夹中添加R.Generated.swift文件,否则它将因错误而崩溃

prepare_command


我生活了一段脚本和痛苦的时光,我决定更广泛地研究这个话题,并找到了另一种选择。
在Podspec中,有prepare_command ,仅用于创建和修改源,然后将其添加到项目中。


*新闻-吊舱的名称,必须替换为您本地吊舱的名称
* 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 

确实,有一个“但是”。 prepare_command不适用于本地吊舱,或者说可以使用,但是在某些特殊情况下。 Github上对此主题进行讨论


死亡人数


*死亡-Wiki,真人快打中的最终热门。


经过更多研究后,我找到了另一个解决方案-方法c prepare_commandpre_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 

最后,它按预期工作。


终于!


PS:


我尝试制作另一个自定义命令,而不是prepare_command ,但pod lib lint (用于验证podspec内容和炉膛本身的命令)发誓会产生额外的变量,并且不会通过。


非本地壁炉


在远程吊舱(每个吊舱都在各自的存储库中)中,您不需要所有上面描述的脚本魔术,因为在那里代码库严格依赖于依赖版本。


只需将Example(pod lib create <Name>命令之后生成的项目)嵌入R.swift脚本本身并将R.Generated.swift添加到库包中(底部)就足够了。 如果项目中没有Example,那么您将必须编写与我引用的脚本相似的脚本。


PS:


有一点澄清:
R.swift + Xcode 10 +新构建系统+增量构建!= <3
有关该问题的更多信息,请参见库的主页此处
R.swift v4.0.0不适用于cocoapods 1.6.0 :(
我认为所有问题很快都会得到纠正。


结论


您始终需要保持尽可能高的质量标准。 这对于使用财务的应用程序尤其重要。


在这种情况下,您不需要过载测试并尽早发现错误。 在我们的情况下,这要么是在开发人员编译代码时,要么是在测试运行请求请求时。 因此,我们发现本地化的不足不是通过测试人员的细心观察或自动测试,而是通过构建应用程序的通常过程进行的。


您还需要考虑以下事实:这是与项目结构相关联并分析其内容的第三方工具。 如果项目文件的结构发生更改,则必须更改该工具。
我们冒了这个风险,在这种情况下,总是可以随时将此工具更改为任何其他工具或自行编写。


R.swift带来的收益是大量的工时,团队可以将其花费在更重要的事情上:新功能,新技术解决方案,质量改进等。 R.swift完全归还了整合所花费的时间,甚至考虑到将来可能用另一种类似的解决方案替换它。


斯威夫特


红利


您可以尝试使用一个示例,以立即亲眼看到为资源生成代码所带来的收益。 “可以玩”的项目的源代码: GitHub


非常感谢您阅读本文或只是浏览到这个地方,无论如何我都很高兴)


仅此而已。

Source: https://habr.com/ru/post/zh-CN431148/


All Articles