Swift Package Manager工程实践

Posted 程序员大咖

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Swift Package Manager工程实践相关的知识,希望对你有一定的参考价值。

👇👇关注后回复 “进群” ,拉你进程序员交流群👇👇

作者丨狐友技术团队

来源丨搜狐技术产品(ID:sohu-tech)

本文字数:12182

预计阅读时间:31分钟

Swift Package Manager(下文简称 SwiftPM )是苹果官方提供的一个用于管理源代码分发的工具,它与Swift构建系统集成在一起,可以自动执行依赖项的下载,编译和链接过程。该工具可以帮助我们编译和链接 Swift packages(包),管理依赖关系、版本控制,以及支持灵活分发和协作(公开、私有、团队共享)等。支持Swift、Objective-C、Objective-C ++、C或C ++。SwiftPM包管理器支持 macOS 和 Linux,与CocoaPods和Carthage功能类似,不过比这两个更简洁,代码的侵入性更小,也不需要额外安装工具。

狐友ios技术团队深度使用了SwiftPM,在实际项目中全面替代曾经长期使用的CocoaPods;本文将详细介绍狐友团队在引入SwiftPM进行工程实践中,探索和累积的相关知识和实践经验,我们将从结构设计、资源处理、链接方式的选择、编译与链接参数设置、异常处理,这五个方面展开详细介绍,每个小部分结尾都提供了最佳实践的总结;希望能够帮助其他想要尝试SwiftPM的开发者顺利过渡;

本文面向了解SwiftPM基本知识,但是没有深度使用的开发者;如果你还不了解SwiftPM,建议首先阅读WWDC相关视频;

  • 结构设计

    代码组织方式

    目录结构

                  创建SwiftPM

                  组织结构与路径设置

          依赖处理

           Objc与Swift混编

          最佳实践

 资源处理

           Package内部几种资源处理的方式

           读取资源

                    读取Asset Catalog中图片

                    获取prcoess处理的资源

                    获取copy处理的资源

            最佳实践

  •  链接方式的选择

                静态链接与动态链接的区别

                SwiftPM编译链接选项

                最佳实践

编译与链接参数设置

         定制编译参数

                  关闭ARC

                  预编译宏设置

          链接参数设置

异常处理

         非Clang Module生成的SwiftPM接入

         无法解决的包管理的问题

总结

参考

结构设计

梳理清楚代码之间的依赖关系,对于设计SwiftPM中,模块如何组成和进行合理拆分,非常重要!

代码组织方式

  • 建议从两个维度考虑组件的组织方式,组件的性质:基础组件,业务组件;代码的依赖关系:通用组件,专用组件;

  • 优先统一代码语言,把Objc代码全部转成Swift后,会很方便整合到SwiftPM中;

  • 如果Objc代码过多,或者这部分OBjc代码是一个完整的功能模块,即不存在Swift/Objc互相依赖混编的情况,也可以拆分到一个SwiftPM中;

  • 如果一个模块依赖另一个模块中的极少数代码,可以考虑复制所需代码到本模块,并标记为非public,解除模块之间耦合关系;

目录结构

推荐使用命令行方式创建SwiftPM;注意命名时尽可能与模块功能相符,不要包含Lib、Framework等不能体现功能的描述;

创建SwiftPM

$ mkdir MyPackage
 $ cd MyPackage
 $ swift package init # or swift package init --type library

默认创建的SwiftPM项目的名字就是文件夹的名字;这将创建带有目标target和相应测试target的库,包含所需的目录结构和单元测试,如下面所示:

Tests           //单元测试
--MyPackageTests
-----MyPackageTests.swift
Sources
--MyPackage     //同顶层目录名的target目录
-----MyPackage.swift
README.md       //文档  
Package.swift   //配置文件,类似Cocoapods自定义pod库的podspec文件

组织结构与路径设置

生成的Package.swift如下面所示:

let package = Package(
     name: "MyPackage",
     products: [
         // Products define the executables and libraries a package produces, and make them visible to other packages.
         .library(
             name: "MyPackage",
             targets: ["MyPackage"]),
     ],
     dependencies: [
         // Dependencies declare other packages that this package depends on.
         // .package(url: /* package url */, from: "1.0.0"),
     ],
     targets: [
         // Targets are the basic building blocks of a package. A target can define a module or a test suite.
         // Targets can depend on other targets in this package, and on products in packages this package depends on.
         .target(             //单独一个Target
             name: "MyPackage",
             dependencies: []),  //此处没有设置path,默认查找Sources下MyPackage目录
         .testTarget(
             name: "MyPackageTests",
             dependencies: ["MyPackage"]),
     ]
 )

由于一个SwiftPM库包可以包含多个target,所以Sources/Tests目录下存放源代码的目录结构组织方式,有不同的选择;有两种方式可供参考:

  1. 单Target模式,默认情况下,SwiftPM自动创建生成对应的target目录,如当前目录结构所示,查找Sources下MyPackage目录,**Sources目录下仅有一个目录MyPackage,且该目录名与target的name参数保持一致**;当需要通过多个目录结构拆分代码时,只能在MyPackage下创建子目录,不能与MyPackage同级;

  2. 多Target模式,在Sources目录下,创建多个平级目录,如targetA,targetB,同时在Package.swift中targets数组中对应进行配置,必要时指明target的path路径;

接下来我们通过一个示例,展示多Target模式,目录结构如下所示,

//...
Sources
--MyPackage     //MyPackage目录
-----MyPackage.swift
--TargetA       //TargetA目录
-----TargetA.swift
--TargetB       //TargetB目录
-----TargetB.swift
//...

多Target模式Package.swift示例代码如下所示,

let package = Package(
    name: "MyPackage",
    products: [
        .library(
            name: "MyPackage",
            targets: ["MyPackage","TargetA","TargetB"]), //1,需要指明对外暴露的target
    ],
    //......省略部分代码
    targets: [
        .target(                   //2,MyPackage定义
            name: "MyPackage",    
            dependencies: []),
        .target(                   //3,TargetA定义
            name: "TargetA",
            dependencies: []),
        .target(                   //4,TargetB定义
            name: "TargetB",
            dependencies: []),
        //......省略部分代码
    ]
)

其中需要特别提到target对象中path参数的设置

static func target(
     name: String,
     dependencies: [Target.Dependency] = [],
     path: String? = nil,
     exclude: [String] = [],
     sources: [String]? = nil,
     resources: [Resource]? = nil,
     publicHeadersPath: String? = nil,
     cSettings: [CSetting]? = nil,
     cxxSettings: [CXXSetting]? = nil,
     swiftSettings: [SwiftSetting]? = nil,
     linkerSettings: [LinkerSetting]? = nil
 ) -> Target

当target的name参数指定的字符串与对应Sources下的目录名称完全一致时,则无需设置path参数,SwiftPM默认查找name参数同名目录;

不符合上述情况时,则必须指定path参数;

  • 如果name参数名与Sources下目录名不一致,则需要指定path参数路径;

  • 如果所有文件直接放在Sources下,没有新建文件夹,则需要指定path参数为path: "Sources"

  • path参数设置时,支持相对路径模式,即可以使用“.”或“..”路径匹配。

依赖处理

SwiftPM类似CocoaPods,可以添加其他依赖的Package,这里以添加本地依赖项为例说明:

let package = Package(
     name: "NetService",
     platforms: [.iOS(.v11)], //指定Package支持的平台
     products: [
         .library(
             name: "NetService",
             targets: ["NetService"]),
     ],
     dependencies: [
         // 当前Package依赖的外部依赖项,以local package相对路径为例
         .package(name: "UserAndSetting", path: "../UserAndSetting"),
         .package(name: "Alamofire", path: "../Product/Alamofire"),
         .package(name: "CryptoSwift", path: "../Product/CryptoSwift")
     ],
     targets: [
         .target(
             name: "NetService",
             dependencies: ["UserAndSetting","Alamofire","CryptoSwift"],//当前target依赖的外部依赖项需要在此处指定,字符串或使用.target参数指定
             path: "Sources"), //此处指定path是Sources目录
         .testTarget(
             name: "NetServiceTests",
             dependencies: ["NetService"]),
     ]
 )
  • Package顶层的dependencies,添加的是外部依赖;外部依赖是指当前Package以外其它SwiftPM

  • 每个target中的dependencies添加的是当前target需要的依赖项;可以是外部依赖,在数组中增加外部依赖的名称;可以是其他的target,使用.target方式引入;

.target(name: "TargetA",
                dependencies: [.target(name: "MyPackage")]),

这样TargetA中可以调用MyPackage中对外提供的API接口;

import MyPackage

struct TargetA
    func testfun() 
        print("TargetA")
        let p = MyPackage()
        p.tttdebug()
    

Objc与Swift混编

SwiftPM中,一个target中,只能存在一种语言,不可以混编;

假设现在有一个完整的业务功能的代码,是混编的,即Swift/Objc代码都有;我们的目标是使用SwiftPM进行模块化处理;一种简单方案是将混编代码中的Objc代码,先转写成Swift代码,这样就不再存在混编问题,下一步处理成SwiftPM是最简单的;

但是,可能由于一些限制或者原因,Objc不能变成Swift;必须混编。

两种改成SwiftPM的解决方案:

  • 多Package模式:将代码按语言不同分开,拆分成两个Package,但是需要满足不存在循环依赖关系,只是单向依赖没有问题;

  • 单Package模式:在一个Package中,Sources文件夹下按语言建立两个单独的目录,分别存放Swift代码和Objc代码,在Package配置文件中建立两个Target,每个Target设置明确的path路径,如path: "Sources/A",同时设置Target的依赖关系;

注意:任何一种方式都需要满足模块代码之间不能循环依赖,如果有这种情况发生,需要先重构代码!

最佳实践

  • 梳理清楚代码之间的依赖关系,对于设计SwiftPM目录结构至关重要;

  • 根据实际情况,选择“单target模式”或“多target模式”组织代码目录结构;

  • 当target的name参数指定的字符串与对应Sources下的目录名称完全一致时,无需设置path参数,其他情况下需要设置;

  • 处理Objc与Swift混编时,需要满足模块代码之间不存在循环依赖关系;

资源处理

在开发过程中,图片、文本、JSON、XML等资源文件,是我们必须使用的;接下来通过一个具体的实例,展示不同类型的资源文件SwiftPM如何处理;这里创建了一个实例工程SpmResourceTest,列举了各种资源文件的存放方式和目录设置;


Package内部几种资源处理的方式

  • 直接把资源文件拖到项目中:放在根目录下,如ic_linkfailed_mid_normal@2x图片,或者放在创建的子目录下,如images目录和json目录,下面都有不同的资源文件;

  • 使用Asset Catalog,在SwiftPM中会默认命名为Media

  • 使用Bundle,如HYContentShare.bundle

DerivedData找到对应的项目编译后的成果


注意到出现了一个SpmResourceTest_SpmResourceTest.bundle的生成文件,显示包内容查看内部结构:


当前target的resources设置

.target(
            name: "SpmResourceTest",
            dependencies: [],
            resources: [.copy("HYContentShare.bundle"),
                        .process("images"),
                        .copy("json")]),

发现一些不同的规律:

  • 没有在配置中明确指定的文件,如ic_linkfailed_mid_normal@2x是不会被处理的,即不被编译进当前bundle中;在编译时,编译器也会出现提醒,例如found 3 file(s) which are unhandled; explicitly declare them as resources or exclude from the target

  • 使用procees处理的文件目录,其目录结构下的内容会被 平铺(减少目录结构层级的一种方式) 放到bundle的根层级下,没有子目录出现,如images目录;

  • 使用copy处理的文件目录,会保留目录层级结构,放在bundle中,如json目录;

  • 资源中如果包含bundle文件,如当前例子中HYContentShare.bundle,不同swift-tools-version版本使用有区别;

    • swift-tools-version:5.5及以下,使用copyprocess没有区别,都会保持HYContentShare.bundle结构;

    • swift-tools-version:5.6及以上,如果使用copy处理,保持HYContentShare.bundle结构;如果改为process处理,则也会将资源平铺放到根目录下,不再有HYContentShare.bundle;如下图所示;


注意:由于Swift Package Manager随着时间推移也在不断迭代,所以需要注意swift-tools-version中指定的版本号,有些功能在高版本与低版本内部实现有差别,某些新功能在高版本才能使用;

// swift-tools-version:5.6

// The swift-tools-version declares the minimum version of Swift required to build this package.

读取资源

下面介绍读取资源的代码实现;

读取Asset Catalog中图片

//从Media直接获得图片,不需要区分2x 3x
UIImage(named: "ic_lianjie_grey_normal", in: .module, compatibleWith: UITraitCollection())!

获取prcoess处理的资源

  • 图片

//方式1,通过路径path
    public func getImageUrl() -> UIImage
        let url = Bundle.module.url(forResource: "ic_right@2x", withExtension: "png")
        let path = url?.path ?? ""
        let image = UIImage(contentsOfFile: path)
        return image!
    
//方式2,使用UIImage的API
    public func getImageUrl2() -> UIImage
        UIImage(named: "ic_right", in: .module, compatibleWith: UITraitCollection())!
    
  • 其他资源 仍然通过Bundle.module.url获得path

let url = Bundle.module.url(forResource: "xzloading_middle", withExtension: "json")
let path = url?.path ?? ""

获取copy处理的资源

//创建获取Bundle路径的扩展函数
extension Bundle
    public static func inner(path:String) -> Bundle
        let bundleURL = Bundle.module.bundleURL
        let subURL  = bundleURL.appendingPathComponent(path)
        return Bundle(url: subURL) ?? Bundle.module
    


//获取copy处理的资源
public func getCopyResource() -> String 
    let url = Bundle.inner(path: "json").url(forResource: "xzloading_big", withExtension: "json")
    var string = ""
    do 
       string =  try String(contentsOfFile: url?.path ?? "")
     catch 
       print("error=\\(error)")
    
    return string

最佳实践

  • 优先使用Asset Catalog管理资源,使用起来最简便;

  • 如果不能使用Asset,比如json文件或者plist,并且可能混合图片资源一起使用(如Lottie动画),优先使用process处理;

  • 如果需要多套同名资源同时存在,如dark/icon.png,light/icon.png,则通过建立多个目录,使用copy处理是合适的,使用时读取不同的path;

链接方式的选择

静态链接与动态链接的区别

我们在编写代码的同时,也需要使用别人提供的库或者框架,就需要使用链接器;链接分为两种类型:

  • 静态链接,它发生在编译构建 App 的时候,影响到构建的耗时以及 App 最终的二进制体积;

  • 动态链接,它发生在 App 启动的时候,影响 App 的启动耗时;

由于上述两种链接方式的区别,一般来说,推荐更多的使用静态链接,减少动态链接,来降低App启动耗时;但是,如果是在开发/调试阶段,频繁修改的代码,建议采用动态链接方式,降低构建的耗时。

SwiftPM编译链接选项

static func library(
    name: String,
    type: Product.Library.LibraryType? = nil,
    targets: [String]
) -> Product

其中type参数的类型是Product.Library.LibraryType,可以通过指定.static.dynamic来决定生成的SwiftPM最终产物的形态,是动态链接库或是静态链接库;

通过下面这张图,解释一下不同的type参数设置,对生成的framework的影响:


  • 我们假设有两个SwiftPM,分别为consumerproducer,其中consumer依赖producer

  • 每个SwiftPM在编译后都会生成对应的中间文件,可重定位对象文件(relocatable object),即.o 文件;

  • consumerproducer的type均设置为nilstatic时,都会仅生成中间文件.o;

  • consumer的type设置为dynamicproducer的type设置为nilstatic,在生成对应的中间文件后,consumer.oproducer.o会进行合并,生成最终的动态链接库consumer.framework

  • consumer的type设置为dynamicproducer的type设置为synamic,在生成对应的中间文件后,consumer.oproducer.o会分别生成对应的动态链接库consumer.frameworkproducer.framework

通过上面的举例描述,我们可以得到下面结论:

  • SwiftPM的LibraryType设置为dynamic时,会生成动态链接库,同时当前库所依赖的库,如果不是设置为dynamic,则会被合并编译成一个动态链接库framework;

最佳实践

  • 如果SwiftPM的产品可以静态链接,也可以动态链接。建议不要明确声明库的类型,这样SwiftPM可以根据包使用者的偏好,选择静态链接还是动态链接。

编译与链接参数设置

定制编译参数

在CocoaPods使用中可以通过配置指定在Debug模式下导入;

pod 'SourceModel', :configurations => ['Debug']

对应的,SwiftPM也可以通过配置实现相同的功能,并且功能更强大;我们通过一段代码示例来展示一下,这段代码源自官方Swift提案;

import PackageDescription

let package = Package(
    name: "BestPackage",
    dependencies: [
        .package(url: "https://github.com/pureswift/bluetooth", .branch("master")),
        .package(url: "https://github.com/pureswift/bluetoothlinux", .branch("master")),
    ],
    targets: [
        .target(
            name: "BestExecutable",
            dependencies: [
                .product(name: "Bluetooth", condition: .when(platforms: [.macOS])),
                //指定生效平台Mac
                .product(name: "BluetoothLinux", condition: .when(platforms: [.linux])),
                //指定生效平台Linux
                .target(name: "DebugHelpers", condition: .when(configuration: .debug)),
                //指定在Debug下生效
            ]
        ),
        .target(name: "DebugHelpers")
     ]
)

关闭ARC

我们在项目中一直使用Google版本的protocolbuffer作为埋点数据序列化工具;到目前为止,它仍然仅支持Objective-C,不支持Swift;

生成的代码是非ARC的,需要特别在BuildSetting中标注; 

如果对代码进行改造使用SwiftPM包管理器进行处理,需要设置参数关闭ARC;

.target(
     name: "ProtobufFiles",
     dependencies: ["Protobuf"],
     path: "Sources",
     publicHeadersPath: ".",
     cSettings: [.headerSearchPath("."), //objc代码指定头文件搜索路径
                .define("GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS"),//设置预编译宏
                .unsafeFlags(["-fno-objc-arc"]) //关闭ARC
     ]),

注意:这种方式针对当前target进行设置,与原有的BuildSetting中针对文件的设置不同,影响target中所有文件;

预编译宏设置

我们继续通过Google版本的protocolbuffer为例分析,通过proto文件生成的Objc头文件包含预编译宏,如下面所示:

#if !defined(GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS)
 #define GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS 0
#endif

#if GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS
 #import <Protobuf/GPBProtocolBuffers.h>
#else
 #import "GPBProtocolBuffers.h"
#endif

参考CocoaPods配置

GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 $(inherited) GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS=1

在taregt的cSettings中需要设置宏,才能编译通过;

.define("GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS")

但是,问题并没有真的解决,当其他模块依赖当前target时,公开暴露出去的 header 的 #if 判断不能使用任何自定义的宏变量,所以均无法编译通过;

因此,在SwiftPM中不能通过任何自定义的宏变量的方式,向外暴露头文件;

链接参数设置

通过前面的分析可以发现,由于Swift Package Manager发展时间相对CocoaPods更短,很多三方组件或开源项目均没有适配SwiftPM,无对应的Package.swift配置,所以当我们项目中自行接入此类项目时,参考对应项目的CocoaPods配置是很好的借鉴方式;

还以高德地图为例,对应项目的CocoaPods配置如下所示:

OTHER_LDFLAGS = $(inherited) -l"c++" -l"z" -framework "CoreLocation" -framework "CoreTelephony" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration"

这里我们借鉴上面的配置,在当前target的linkerSettings中增加必要的链接参数;

linkerSettings: [.linkedLibrary("c++"),
                             .linkedLibrary("z"),
                             .linkedFramework("ExternalAccessory"),
                             .linkedFramework("ImageIO"),
                             .linkedFramework("MobileCoreServices"),])

一般来说,通过这种方式调整后,都可以顺利编译通过,正常使用。

异常处理

接下来介绍一些使用SwiftPM可能会遇到的特殊情况,如何处理。

非Clang Module生成的SwiftPM接入

Clang Module包含module.modulemap,某些三方SDK历史版本比较老,生成的XCFramework不含module.modulemap,在通过SwiftPM接入项目主工程时,例如MAMapKit(高德地图),这类模块无法直接暴露给Swift,必须通过桥接方式引入;

#import <MAMapKit/MAMapKit.h>  //高德地图SDK需要保留,MAMapKit没有被编译为Mudule,Swift引用不到,必须通过桥接方式使用

无法解决的包管理的问题

如果使用Xcode + SwiftPM 遇到奇怪问题,使用过了Clean Build Folder,删除DerivedData,重启Xcode等一系列方式后,仍然无法解决,请尝试下面方式;

rm -rf ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/org.swift.swiftpm

总结

Swift Package Manager作为苹果推出的包管理依赖工具,可以说是补足了苹果生态的短板;SwfitPM相比Cocoapods它配置更加简洁易用,相比Carthage更加轻量化,无入侵;因为Swift本身是跨平台语言,SwfitPM完全使用Swift编写,所以使用场景不仅仅局限于Mac平台;

随着今年WWDC22上Swift Package Plugins的发布,解决了SwfitPM目前不支持在构建期间执行任何自定义操作的问题,包括源代码生成以及特殊类型资源的自定义处理等问题;为提高研发流程效率,更加自动化提供了有力的支持;

参考

  • https://github.com/apple/swift-evolution/blob/master/proposals/0273-swiftpm-conditional-target-dependencies.md#proposed-solution

  • https://useyourloaf.com/blog/add-resources-to-swift-packages/

  • https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package

-End-

最近有一些小伙伴,让我帮忙找一些 面试题 资料,于是我翻遍了收藏的 5T 资料后,汇总整理出来,可以说是程序员面试必备!所有资料都整理到网盘了,欢迎下载!

点击👆卡片,关注后回复【面试题】即可获取

在看点这里好文分享给更多人↓↓

以上是关于Swift Package Manager工程实践的主要内容,如果未能解决你的问题,请参考以下文章

Swift Package Manager 无法解析 1.4 以上的 Kitura 版本

Swift Package Manager 重写 macOS 部署目标

如何解决 Swift Package Manager 目标重叠源错误

SwiftUI 简明教程之 Swift Package Manager 的使用

使用 Swift Package Manager 将 Google Firebase Admob 添加到 iOS 项目 [关闭]

Swift Package Manager 在使用 Almofire 时使用未解析的标识符“AF”