iOS工程化「五」cocoapods插件-生成xcFramework

1,880 阅读13分钟

podspec文件查询方法

podspec文件是cocoapods的自定义的dsl,实际上是Spec这个类中定义的方法,所有的方法都在Cocoapods/Core里面。Spec这个类的定义就在dsl.rb文件中,里面每个参数的意义和每个参数怎么写都有详细说明。 如果要查看Podfile文件怎么书写,可以进入Podfile对应的dsl查看。

1. 创建cocoapods插件

cocoapods内置了创建插件的功能,终端输入pod plugins --help

image.png

可以通过create命令直接创建插件,或者可以创建一个gem把它变成cocoapods插件。 我们使用后者。

2. 创建gem工程

cd到要创建工程的目录,执行bundle gem cocoapods-sj-bin,一顿回车,就可以创建出cocoapods-sj-bin这个工程了。

image.png

3. 修改配置

修改cocoapods-sj-bin.gemspec文件。制作的是gem的三方库,三方库的信息都在这个文件中。

image.png 首先,spec.version是依赖version.rb这个文件的。 修改文件夹:

  • cocoapods文件夹名字修改为cocoapods-sj-bin
  • bin.rb文件放到cocoapods-sj-bin目录下
  • 删除sj文件夹
  • cocoapods-sj-bin目录下创建文件gem_version.rb
4. 在gem_version.rb写代码

首先要给三方库起一个命名空间,防止类名冲突,包括别人可以通过命名空间访问到我们的代码。 在Ruby里面,起命名空间使用module。在命名空间可以直接定义变量:

module SjBin
  VERSION = '0.1.0'
end
5. 修改cocoapods-sj-bin.gemspec中的version

如果要访问gem_version.rb,首先要保证require里面的路径是正确引用这个文件的。

可以参照Xcodeproj-master项目里面xcodeproj.gemspec的写法,先复制过来require File.expand_path('../lib/xcodeproj/gem_version', __FILE__)

FileRuby的一个内置类,专门用来op fileexpand_path:基于当前文件,再基于后面写的相对路径,变成绝对路径。

修改后路径引用:

require File.expand_path('../lib/cocoapods-sj-bin/gem_version', __FILE__)

修改version

spec.version = SjBin::VERSION

删除

spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
  spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
  spec.bindir = "exe"

spec.files代码中哪些文件需要被别人引入过去,与podspec中的source_files类似。 再抄一下s.files = %w(README.md LICENSE) + Dir['lib/**/*.rb']Dir代表lib文件夹下的文件夹中的所有.rb文件,%w后面可以用{}()[],代表定义了一个Ruby数组,里面放一组文件,中间用空格隔开。

bin文件夹下存放着终端可以直接调用的ruby文件。可以通过executables声明这个三方库是支持在终端直接调用的,会把当前文件加载到ShellPATH搜索路径中。把bin文件夹中的console重命名sj_bin

spec.executables   = %w(sj_bin)

spec.require_paths = ["lib"]这个路径是从lib开始的,如果想使用里面的bin.rb,把bin.rb放到lib文件夹下。

添加依赖,因为我们是基于cocoapods进行开发,所以使用add_runtime_dependency运行时以来的三方库:

spec.add_runtime_dependency 'cocoapods', '~>1.10.1'
6. 修改Gemfile

注释掉gem "rake", "~> 13.0"

7. 修改bin.rb

想把这个文件作为暴露的文件,别人可以直接使用。比如他导入了这个文件,就代表导入当前的整个gem。修改下名称cocoapods-sj-bin.rb

8. bundle install
  spec.summary = "bin gen tool"
  spec.description = "bin gen tool"
  spec.homepage = "https://github.com/CocoaPods/CocoaPods"
  spec.required_ruby_version = ">= 2.6.0"
9. 创建cocoapods_plugin.rb文件

cocoapods要求在lib文件夹下必须有cocoapods_plugin.rb文件,自己写的命令需要写在这个文件里,cocoapods才能读到这个命令,终端才能使用这个命令。

10. 创建launch.json文件

如果现在调试bin文件夹下的sj_bin文件,json文件内容:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Sj Bin",
            "type": "Ruby",
            "request": "launch",
            "debuggerPort": "1237",
            "program": "${workspaceRoot}/bin/sj_bin",
            "args": []
        }
    ]
}
11. 调试

sj_bin文件写:p 'Sj1234',按F5开始调试,终端输出Sj1234

image.png

12. 创建自己命令

比如现在想实现pod sj这个命令,sj就是pod的子命令,仿照cocoapodslib->cocoapods-sj-bin文件夹下创建文件夹command,在command下创建文件sj.rb。 要使用cocoapods命令,首先要require引用cocoapodsrequire 'cocoapods'

之后怎么写可以参照cocoapods,在lib->cocoapods->command下比如init.rb文件:

image.png

Init小写就是给cocoapods定义的命令,<代表继承,给cocoapods定义一个sj命令代码如下:

module Pod
  class Command
    class Sj < Command
    end
  end
end

self.summary = 'G.... 简介 self.description = <<-DESC... 描述 def initialize(argv).... 类似OCinit方法,接收参数argv def run... 调用sj这个命令时候,真正想做的操作

修改后代码:

module Pod
  class Command
    class Sj < Command
      self.summary = 'sj bin'
      self.description = 'sj bin'

      def initialize(argv)
        super
      end

      def run
        p '111111'
      end
      #-----------------------
    end
  end
end

run方法打个断点,调试下能不能过来,那就要调试sj.rb这个文件,之前我们调试sj_bin这个文件,那就要在这个文件中调用sj.rb。 首先要引入,按照cocoapods-sj-bin.gemspec中的spec.require_paths来搜索:require 'cocoapods-sj-bin/command/sj.rb'

然后我们调用Command,要先使用Pod命名空间才能调用到CommandPod::Command此时调用的是pod这个命令,如果再想调用sj,可以通过参数传递给ARGVPod::Command.run(ARGV)ARGV就是通过launch.json传递的参数,所以args里面要写sj

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Sj Bin",
            "type": "Ruby",
            "request": "launch",
            "debuggerPort": "1237",
            "program": "${workspaceRoot}/bin/sj_bin",
            "args": [
                "sj"
            ]
        }
    ]
}

F5调试,发现报错:

image.png

这个报错就是在require之前,要使用bundler/setup来清空一下当前LOAD_PATH,在require之前添加代码:

if $PROGRAM_NAME == __FILE__
  ENV['BUNDLE_GEMFILE'] = File.expand_path('../Gemfile', __dir__)
  require 'bundler/setup'
end

F5调试,就可以进入到run方法了。

13. 创建build命令

做组件二进制化,首先可以想到两个命令,build:把代码组件变成二进制组件,debug:调试组件二进制原码。 在command文件夹下创建build.rb,如果我想调用pod sj build这种形式,意味着buildSj的子命令:

require 'cocoapods'

module Pod
  class Command
    class Sj < Command
      class Build < Sj
        self.summary = 'sj bin'
        self.description = 'sj bin'

        def initialize(argv)
          super
        end

        def run
          p '111111'
        end
      #-----------------------
      end
    end
  end
end

sj_bin引入修改为require 'cocoapods-sj-bin/command/build.rb',引入build

launch.jsonargs里面增加个参数build

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Sj Bin",
            "type": "Ruby",
            "request": "launch",
            "debuggerPort": "1237",
            "program": "${workspaceRoot}/bin/sj_bin",
            "args": [
                "sj",
                "build"
            ]
        }
    ]
}

F5调试就没有问题了。

14. 设置命令参数,解析参数

这个build命令就是制作二进制包,考虑下他是怎么制作的,首先去下载AFNetworking源码。

比如我现在要把AFNetworking源码生成二进制组件,就需要进入AFNetworking执行build来生成xcframework,推到指定的源,在使用时下载下来即可。

生成二进制组件需要的参数:

  1. framework还是library,是静态framework还是动态framework
  2. 在哪个环境生成,debug还是release
  3. 支持的平台

我们先写options,这里面的内容就是当执行--help的时候,会把options的内容输出出来:

def self.options
            [              ['--platforms=ios,macos', 'Lint against specific platforms (defaults to all platforms supported by the ' \                'podspec). Multiple platforms must be comma-delimited'],
              ['--library', '构建静态产物,默认为true'],
              ['--framework', '构建静态Framework,默认为false'],
              ['--dynamic', '构建动态Framwork,獸认为false'],
              ['--configuration', 'Build the specified configuration. Default to Release'],
              ['--sources', '构建工程需要依赖的sources源']
            ].concat(super).uniq
          end

这些只是声明的参数,接下来要在initialize方法去解析这些参数,cocoapods提供了内置的解析工具。比如解析dynamicargv.flag?('dynamic', false)Ruby里面问号返回是Bool类型,第二个参数是默认值。 我们解析了这些值,还需要一个属性去保存它,Ruby中定义类属性用@符号,实例属性用@@定义,定义一个属性:@product_type = :framework。里面的:代表定义了一个Ruby的符号,这个属性可以定义字符串形式保存比如@product_type = "framework",定义字符串需要开辟空间存储它,并且还要存它的符号,用:表示直接定义了一个符号去使用。

@product_type = :framework
@product_type = :static_library if argv.flag?('library', false)
@product_type = :dynamic_framework if argv.flag?('dynamic', false)

定义的product_type默认值是framework也就是静态framework。如果设置library,产物是静态库,如果设置dynamic产物就是动态framework

平台参数,因为支持平台写的是-platforms=*****,这个接收参数的在cocoapods里面叫option,默认参数写空字符串,如果不传的话,这个组件支持什么类型就编译什么平台类型。

@platforms           = argv.option('platforms', '').split(',')

环境,默认Release

@configuration = argv.option('configuration', 'Release')

source我们先写个空

@sources = []
15. 编译逻辑

修改run方法里面代码。

  1. 要把准备编译的源码进行拷贝,为了防止修改导致原工程出问题
  2. 构建当前平台架构支持的APP工程,把源码导入进去,然后生成二进制
  3. 编译产物
  4. 不同架构编译产物打包成xcframework
16. 定义root目录

这个目录代表每次要拷贝的目录是哪个。Pathname这个类就可以通过点语法形式操作路径。定义拷贝路径:

def root
   # 所有要进行二进制的库都拷贝到这个目录下
   Pathname.new("/private/tmp/cocoapods-bin")
end
17. 定义方法处理podspec文件

我们创建App工程都是根据podspec文件,所以定义一个属性接收所有的podspec文件路径:@podspec_paths = []

定义一个方法:

def podspecs_to_build
  if @podspec_paths.empty?
    @podspec_paths = Pathname.glob(Pathname.pwd + '*.podspec{.json}')
    # 如果赋值完还是空
    if @podspec_paths.empty?
      # 抛出异常
      raise Informative, 'Unable to find a podspec in the working' \
        'directory'
    end
  end
  @podspec_paths
end
18. 拷贝工程

File.expand_path可以把部分路径恢复成完整路径。 Pathname.pwd.parent获取当前路径的上一层。 Dir.chdir("/Users/shangjie/Desktop/工程化/代码/AFNetworking-master")让当前的工作目录进入指定的目录。

拷贝前要把要拷贝到的目录进行清空,需要调用Ruby内置的一个工具类require 'fileutils'

copy_path = Pathname.pwd
name = File.basename(Pathname.pwd)
# 要拷贝到的目录
root_path = root
# 清理目标目录
FileUtils.rm_rf(root_path)
# 清理完再重新创建下目标目录
root_path.mkpath
# copy
FileUtils.cp_r(copy_path, root_path)
19. 创建工程

定义一个app的目录:

def app
  root.join('App')
end

pod lib lint验证pod库也是创建个工程,然后去github把代码拉下来,按照podspec添加到工程里面,然后进行编译。

cocoapods有一些三方工具可以使用,直接创建App工程,比如cocoapods-generate,这个插件的描述A CocoaPods plugin that allows you to easily generate a workspace from a podspec.,就是一个cocoapods差价比较容易根据podspec去生成一个worksoace
这个插件有小小的bug,应对99%以上场景都可以,但是特殊的就不行。

我们知道cocoapods导入第三方库有两种,一种走网络,一种走本地:

image.png

  1. 走网络:根据podspecgithub把源码下载下来,在根据podspec去清理不需要的文件。cocoapods内置有清理工具。这就是为什么比如AFNetworking里面有很多文件,但是导入项目后只有很少一部分文件的原因。
  2. 走本地:并不会拷贝文件,只是添加引用。

为什么cocoapods-generatebug?因为这个插件是用path的方式导入的第三方库。我们还原一下这个bug。 创建一个测试工程,下载reakm源码并放到工程目录下。 创建Podfile文件:

target 'test' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  pod 'Realm', :path => './realm-swift-master'
end

执行pod install。 编译项目,可以看到报了很多错误:

image.png

image.png

这个错误就是在RLMUtil.mm文件中重复引用了RLMInt。打开RLMUtil.mm,选择Preprocess预处理这个文件,预处理的作用就是把文件导入到这个文件,搜索RLMInt,可以找到有两个@protocol RLMInt @end声明。

出现这个错误的原因是我们使用Realm源码是通过链接的方式引入的,编译器在编译的时候,会在.mm文件的当前目录搜索.h文件,在同目录找到了.h文件,cocoapods又在include中配置了头文件的搜索路径。

image.png

出现这个问题的根源在于podspec文件中有个s.prepare_command         = 'sh scripts/setup-cocoapods.sh'。说明如下:

image.png

image.png

打开这个脚本,框住的内容就是把Realm文件下的所有.h文件拷贝到include下。

既然有这个bug,那我们就使用本地网络结合的方式,如果走网络,那就还是网络,如果走本地,我们按照网络的流程把代码拷贝到pods目录下,这样上面的bug就可以解决了。

有位靓仔写的cocoapods-project-gen就可以完美实现我们这个需求。

首先导入require 'cocoapods-project-gen',在cocoapods-sj-bin.gemspec文件中添加依赖spec.add_runtime_dependency 'cocoapods-project-gen',在终端中执行bundle install

我们生成工程是根据Podifle文件里面的platfrom,一个platfrom生成一个工程。

# 创建工程
generator = ProjectGen::ProjectGenerator.new(podspecs_to_build.first, [], @platforms)
generator.local = false
generator.no_clean       = false
generator.allow_warnings = true
generator.no_subspecs = true
generator.only_subspec   = false
generator.use_frameworks = @product_type == :dynamic_framework
generator.use_static_frameworks = @product_type == :framework
#          generator.external_podspecs = external_podspecs.drop(1)
generator.skip_import_validation = true
# generator.swift_version = swift_version
generator.configuration = @configuration
generator.skip_tests = true

begin
  generator.generate!(app) do |platform, pod_targets, validated|
      raise 'Could not generator App.xcodeproj' unless validated

  end
rescue StandardError => e
  raise Pod::Informative, "The `#{@include_podspecs.join(' ')}` specification does not validate." \
                  "\n\n#{e.message}"
end

直接按F5就可以看到运行成功,/private/tmp/cocoapods-bin目录下生成App的项目:

image.png

编译也是没问题的,这里有个注意点,你项目路径是不能包含中文的,否则就会报错:

image.png

我们把AFNetworking换成Realm,修改Dir.chdir("/Users/shangjie/Desktop/testtttt/realm-swift-master")

编译出来的不知道为何是macos的,在launch.json文件中添加参数"--platforms=ios"即可,生成项目可以编译成功了。

这里面有个可以优化的点,在raise 'Could not generator App.xcodeproj' unless validated这行代码打断点,可以发现这个断点走了好几次,就是我们指定platformscocoapods会根据平台复制三方库生成App复制三方库生成App复制三方库生成App重复流程,我们一颗让他只复制一次三方库,不同平台生成不同target

20. 编译

编译我们有两个选择:

  1. 之前我们生成的工程是完整App+pods,我们可以编译这个App,通过隐性依赖编译Pods_App通过显式依赖编译pods
  2. 通过pods直接编译pod,通过scheme方式来编译。
    为了减小编译量,我们选择通过scheme直接编译pods

接下来编译方式有两种:

  1. build:产物目录不能控制,有个App名字加随机字符串,动态库的调试符号放在framework下。
  2. Archive:产物目录可以控制,动态库的调试符号放在指定目录下,对xcframework格式的支持更好。

在编译swift相关的库的时候,最好把BUILD_LIBRARY_FOR_DISTRIBUTION打开,每个人指定swift编译器可能不一样,就会报一些错误。

编译命令

  1. 对于模拟器,要生成两个架构:x8664arm64
  2. 对于.a库头文件处理
  3. Swift模块文件处理
xcodebuild clean archive BUILD_LIBRARY_FOR_DISTRIBUTION=YES \
-project 'Pods/Pods.xcodeproj' \
-scheme 'Pods-App' \
-destination "generic/platform=iOS Simulator" -sdk 'iphonesimulator' \
-archivePath './archive/Pods-App.xcarchive' \
SKIP_INSTALL=NO -showBuildTimingSummary

SKIP_INSTALL这个环境变量默认是YES,控制产物是否放在archive目录里面,默认是不放的,所以我们要改成NOgeneric/这个含义你光指定编译架构还不行,你需要指定具体的机型,或者不指定具体机型,选择一个通用的你帮我选择一个即可。

我们可以通过下面命令打印编译时需要的环境变量:

xcodebuild clean archive BUILD_LIBRARY_FOR_DISTRIBUTION=YES \
-project 'Pods/Pods.xcodeproj' \
-scheme 'Pods-podtest' \
-destination "generic/platform=iOS Simulator" \
-showBuildSettings

搜索Archs,可以看到支持的架构是arm64

image.png

我们再制定-sdk 'iphonesimulator',可以看到archs支持架构变成了arm64 x86_64

image.png

编译产物需要单独放一个目录,定义一个产物目录:

/// 专门放产物的东西
def product_dir
  Pathname.new(root).join('./Products').expand_path
end

/// 放其他头文件等
def archive_dir
  Pathname.new(product_dir).join('./archive').expand_path
end

一个platform生成一个app工程,我们去编译产物,保存archive路径,最后通过这些路径生成xcframework。 我们在编译的时候,destinationsdkarchivePath这三个参数是变化的,剩下的参数是固定不变的。

framework里面的头文件,都是按结构放在.framework/Headers里面,它进行了格式化。.a库并不是。 podfile文件把use_frameworks!删除,默认生成的就是.a库。找一个工程编译.a库,编译完Show Build Folder In Finder,找到路径Build/Products/Debug-iphonesimulator/MJRefresh,可以看到就一个.a库,其他什么信息也没有。现在的头文件是cocoapods按照格式放在了Pods/Headers/Private/MJRefreshPods/Headers/Public/MJRefresh里面。只有.a库会这么处理,通过软链接的方式显示。如果现在生成二进制文件,就需要把这个头文件按cocoapods的规则给保存起来。

现在我们打开use_frameworks!,再编译可以在产物信息里看见头文件了:

image.png

cocoapods源码中有个link_headers方法,里面如果是use_frameworks直接next什么也不做。如果不是,按软连接方法显示,我们只需要把这个方法进行一些修改,复制出真实文件即可。

def link_headers(target, product_path)
  Pod::UI.message '- Linking headers' do
    # When integrating Pod as frameworks, built Pods are built into
    # frameworks, whose headers are included inside the built
    # framework. Those headers do not need to be linked from the
    # sandbox.
    next if target.build_as_framework? && target.should_build?

    sandbox = Pod::Sandbox.new(product_path)
    build_headers = AmberBin::HeadersStore.new(sandbox, '', :public)
    pod_target_header_mappings = target.header_mappings_by_file_accessor.values
    public_header_mappings = target.public_header_mappings_by_file_accessor.values
    headers = pod_target_header_mappings + public_header_mappings
    headers.uniq.each do |header_mappings|
      header_mappings.each do |namespaced_path, files|
        hs = build_headers.add_files('', files)
        build_headers.add_files(namespaced_path, hs, ln: true)
      end
    end
    root_name = Pod::Specification.root_name(target.pod_name)
    pod_dir = target.sandbox.sources_root.join(root_name)
    # swift头文件处理
    module_headers = pod_dir.join(Constants::COPY_LIBRARY_SWIFT_HEADERS)
    build_headers.add_files(root_name, module_headers.children)
    build_headers.root
  end
end

这个方法还有个swift头文件处理,-Swift.h文件是放在产物目录下的,还有一些module等文件,这些也需要处理。这些文件是cocoapods通过脚本拷贝到产物目录的:

image.png

我们选择在生成xcode工程阶段也生成一个脚本,把swift相关的文件拷贝到指定目录。 cocoapods源码中,add_swift_library_compatibility_header_phase就是添加脚本的方法。cocoapods-project-gen中也用类似方法add_swift_library_compatibility_header去添加了脚本。

XCFramework:是苹果官方推荐的、支持的,可以更方便的表示一个多 个平台和架构的分发二进制库的格式。 需要Xcode11以上支持。 是为更好的支持Mac CatalystARM芯片的macOS。 专⻔在2019年提出的framework的另一种先进格式。

和传统的framework相比:

  1. 可以用单个.xcframework文件提供多个平台的分发二进制文件;
  2. Fat Header相比,可以按照平台划分,可以包含相同架构的不同平 台的文件;
  3. 在使用时,不需要再通过脚本去剥离不需要的架构体系。
21. 创建私有库

把创建的xcframework推到自己的source