跟着 SPM( Swift Package Manager ) 功能的不断完善,越来越多的开发者开始在他的项目中经过创立多个 Package 的办法来别离功能、办理代码。SPM 自身供给了对包中各类资源( 包括本地化资源 )的办理才能,但首要局限于在本包中运用这些资源,难以将资源进行同享。在有多个 Target 均需调用同一资源的状况下,原有的办法很难应对。本文将介绍一种在具有多个 SPM 包的项目中,对资源进行一致办理的办法。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

问题

笔者最近正在运用 TCA( The Composable Architecture )结合 SwiftUI 做一些开发,在 TCA 中,开发者通常会为一个 Feature 创立一个独立的包或在一个一致的包( 具有众多的 Target )中创立一个独自的 Target。Feature 中通常会包括有关 UI 的逻辑处理代码( Reducer )、单元测验代码、与该 Feature 相关的视图代码以及预览代码。每个 Feature 基本上能够被视作一个可独立运转的小运用( 在注入所需的环境后 )。终究开发者需求经过在 Xcode 项目中导入所需的 Feature 模块,并经过串联代码将完好的 app 组合出来。在这种状况下,几乎每个 Feature 以及 Xcode 项目代码都需求运用到本地化及其他一些共用资源。

假定将共用资源别离复制到不同模块的 Resource 目录中,那么会造成如下的问题:

  • 每个模块中都有重复的资源,运用的尺度将增大
  • 难以办理共用资源,或许会呈现更新不同步的状况

假如一切的模块都位于同一个目录下,经过运用相对路径的办法,能够在各自的 Resources 目录中导入共用资源,这样尽管能够防止上述的更新不同步的状况,但仍需面临两个问题:

  • 每个模块中都有重复的资源,运用的尺度将增大
  • 模块与资源文件之间的耦合度添加,不利于用多个库房来别离办理

总之,最好能有一种办法能够做到:

  • 资源与模块和 Xcode 项目之间低耦合度
  • 能够一致办理资源,不会呈现不同步
  • 在终究的运用中只需求保存一份资源复制,不会造成存储的浪费

思路

Bundle 为代码和资源的组织供给了特定结构,意在提高开发者的体会。这个结构不只允许预测性地加载代码和资源,一起也支撑类似于本地化这样的系统性特性。Bundle 在存储上以目录的形式存在,在代码中则需求经过 Foundation 结构中的 Bundle 类来表现。

Xcode 工程项目自身便是在一个 Bundle 之下,开发者能够运用 Bundle.main 来获取其间的资源。

在 SPM 中,假如咱们为 Target 添加了资源,那么在编译的时分,Xcode 将会自动为该 Target 创立一个 Bundle ,名称为 PackageName_TargetName.bundle( 非 Mac 渠道,尾缀为 resources )。假如咱们能够在其他的 Target 中获取到该 Bundle 的 URL ,并用其创立一个 Bundle 实例,那么就能够用下面的办法运用该 Bundle 中的资源:

Text("MAIN_APP", bundle: .i18n)
      .foregroundColor(Color("i18nColor", bundle: .i18n))

因而,创立一个能够在任何状况下指向特定目录的 Bundle 实例便成了解决问题的关键。之所以强调任何状况,是因为,Swift 会视项目的编译需求而将 Bundle 放置在不同的目录层级上( 例如独自编译 SPM Target 、在 SPM 中进行 Preview、在 Xcode 工程中引入 SPM Target 后编译运用等 )。

幸运的是,Xcode 为咱们供给了一段展现如何创立可应对多种编译状况下 Bundle 实例的示例代码。

在 SPM 中,假如你为 Target 添加了至少一个资源,那么 Xcode 将会为你创立一段辅佐代码( 该段代码并不包括在项目中,只在 Xcode 中起作用 ),生成一个指向该 Target Bundle 的实例:

在多包项目中统一管理资源

代码如下:

private class BundleFinder {}
extension Foundation.Bundle {
    /// Returns the resource bundle associated with the current Swift module.
    static let module: Bundle = {
        let bundleName = "BundleModuleDemo_BundleModuleDemo" // PackageName_TargetName
        let overrides: [URL]
        #if DEBUG
        if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
            overrides = [URL(fileURLWithPath: override)]
        } else {
            overrides = []
        }
        #else
        overrides = []
        #endif
        let candidates = overrides + [
            // Bundle should be present here when the package is linked into an App.
            Bundle.main.resourceURL,
            // Bundle should be present here when the package is linked into a framework.
            Bundle(for: BundleFinder.self).resourceURL,
            // For command-line tools.
            Bundle.main.bundleURL,
        ]
        for candidate in candidates {
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        fatalError("unable to find bundle named BundleModuleDemo_BundleModuleDemo")
    }()
}

该段代码的基本逻辑是供给了三种或许的 Bundle 寄存方位:

  • Bundle.main.resourceURL
  • Bundle(for: BundleFinder.self).resourceURL
  • Bundle.main.bundleURL

在创立 Bundle 实例时,逐个方位查找,直到找到对应的 Bundle 目录后再创立实例。随后,咱们就能够在代码中运用这个 Bundle.module 了 :

Text("Hello",bundle: .module)

很遗憾,上述的代码并没有掩盖全部的或许性,譬如在当时 Target 中运转 SwiftUI 的预览代码,就会呈现无法找到对应的 Bundle 的状况。不过这现已为咱们指明晰路途,只需供给的备选方位满足充沛,那么就有在任何场景下都成功创立对应的 Bundle 实例的或许。

实践

本节,咱们将经过一个具体事例来演示如安在一个具有多个包的 Xcode 项目中一致办理资源。能够在 此处 取得项目代码。

演示项目中,咱们将创立一个名为 UnifiedLocalizationResources 的 Xcode 工程。并在其间创立三个 Package :

  • I18NResource

    保存了项目中一切的资源,别的还包括一段创立 Bundle 实例的代码

  • PackageA

    包括了一段 SwiftUI 视图代码以及一段预览代码,视图中运用了 I18NResource 的资源

  • PackageB

    包括了一段 SwiftUI 视图代码以及一段预览代码,视图中运用了 I18NResource 的资源

在多包项目中统一管理资源

一切的资源都保存在 I18NResource 的 Resources 目录下,PackageA、PackageB 以及 Xcode 工程代码中都将运用同一份内容。

I18NResource

  • 在 Target 对应的目录下创立 Resources 目录
  • 修正 Package.swift,添加 defaultLocalization: "en", 启用本地化支撑
  • 在 I18NResource.swift 中添加如下代码:
private class BundleFinder {}
public extension Foundation.Bundle {
    static let i18n: Bundle = {
        let bundleName = "I18NResource_I18NResource"
        let bundleResourceURL = Bundle(for: BundleFinder.self).resourceURL
        let candidates = [
            Bundle.main.resourceURL,
            bundleResourceURL,
            Bundle.main.bundleURL,
            // Bundle should be present here when running previews from a different package "…/Debug-iphonesimulator/"
            bundleResourceURL?.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent(),
            bundleResourceURL?.deletingLastPathComponent().deletingLastPathComponent(),
            // other Package
            bundleResourceURL?.deletingLastPathComponent()
        ]
        for candidate in candidates {
            // 对于非 mac 苹果,能够需求运用 resources 尾缀
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        fatalError("unable to find bundle named \(bundleName)")
    }()
}

代码与 Xcode 自动生成的 module 代码很类似( 便是在其基础上做的修正 ),但添加了三个新的候选项以习惯更多的场景。现在只需调用 Bundle.i18n ,就能够根据所处环境生成正确的 Bundle 实例了。

  • 添加资源文件

    在多包项目中统一管理资源

PackageA

  • 修正 Package.swift

    添加 defaultLocalization: "en", 在 Package 的 dependencies 中添加 .package(path: "I18NResource") ,在 PackageA target 的 dependencies 中添加 .product(name: "I18NResource", package: "I18NResource")

  • 修正 PackageA.swift 代码

import I18NResource // 导入资源库
import SwiftUI
public struct ViewA: View {
    public init() {}
    public var body: some View {
        Text("HELLO_WORLD", bundle: .i18n) // 运用 Bundle.i18n
            .font(.title)
            .foregroundColor(Color("i18nColor", bundle: .i18n)) // 运用 Bundle.i18n
    }
}
struct ViewAPreview: PreviewProvider {
    static var previews: some View {
        VStack {
            ViewA()
                .environment(\.locale, .init(identifier: "zh-cn"))
            VStack {
                ViewA()
                    .environment(\.locale, .init(identifier: "zh-cn"))
            }
            .environment(\.colorScheme, .dark)
        }
    }
}

在多包项目中统一管理资源

现在咱们现已能够在 PackageA 中运用 I18NResource 中的资源了。

PackageB 的操作与 PackageA 基本一致

Xcode 工程

  • 为工程导入 PackageA 和 PackageB

在多包项目中统一管理资源

  • 修正 ContentView.swift

在多包项目中统一管理资源

无需在 Xcode 工程中独自导入 I18NResource 模块,也能够直接运用其间的资源。

至此,咱们便完成了本文的初衷:一个低耦合度、不添加容量、不会呈现更新版本过错的一致资源办理办法。

总结

开发者不该仅仅将 SPM 视为一种包工具,应将其视为能够让你的项目以及开发才能取得提高的机遇。

跟着时间的推移,每个模块都能够同享、测验和改善。对我来说,这不只仅是一个小小的变化——这是一个巨大的飞跃。我的项目在每个等级都有所改善——它更安稳、更可测验,甚至更快。这并不是说 Swift Packages 有一个秘密功能能够让你的项目运转得更好。创立 Swift 包的过程迫使您采取良好和健康的步骤来终究改善您的项目,例如测验、API 规划、依靠注入、文档编写等等。一旦我这样做了,我就意识到模块化我的代码,组织起来,并运用 “API 驱动” 的规划是多么重要。 —— 摘自:Mastering Swift Package Manager

期望本文能够对你有所协助。一起也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

我正以聊天室、Twitter、博客留言等评论为灵感,从中选取有代表性的问题和技巧制作成 Tips ,发布在 Twitter 上。每周也会对当周博客上的新文章以及在 Twitter 上发布的 Tips 进行汇总,并经过邮件列表的形式发送给订阅者。

订阅下方的 邮件列表,能够及时取得每周的 Tips 汇总。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】