利用 Jazzy + SourceKitten 生成多依赖库的在线文档

2,205 阅读6分钟
Cocoa Documentation By Jazzy & SourceKitten.png
Cocoa Documentation By Jazzy & SourceKitten.png

我们在前文 《Cocoa 代码注释与文档生成》 中详细介绍了如何为 Swift & ObjC 的代码编写符合规范的注释,以及使用 Jazzy 来生成项目文档。 今天我们来尝试一下,如何一键生成多个私有库的文档,并将其部署到 Github page 或者 Gitlab page 上。

本文知识目录

利用 Jazzy + SourceKitten 生成多依赖库的在线文档.png
利用 Jazzy + SourceKitten 生成多依赖库的在线文档.png

背景

随着公司项目的迭代,一般都会沉淀出多个私有库。如果这些私有库可以够提供统一的文档查询和预览服务,那将有助于团队中的新成员快速了解业务。

作者所在的公司就维护者 20 多个的私有库,同时这些项目的代码注释完整度不一,注释的内容也参差不齐。如果我们可以通过这个在线文档,不仅可以提供快速的 API 查阅能力,也可以更好的监督和规范项目。

如何一键生成多依赖库的文档

我们先来简单分析一下要实现这个想法 💡需要做哪些事情。

  1. 现有的文档生成工具都是基于单个项目,而我们想要的是多依赖库的集合文档。那么就需要有一个索引页将各个依赖库串联起来,能够通过索引来访问它。
  2. 由于公司的项目是包含了 Swift & ObjC 混编的庞大项目,所维护的私有仓库不仅包含了纯 ObjC 和纯 Swift 实现的,还包括了 Swift & ObjC 混编代码的依赖库。所以需要支持这个三种场景。
  3. 生成的文档都是静态页面,需要将这些页面托管在静态资源服务上,关于这点 Github Page 和 Gitlab Page 就能解决。
  4. 毕竟项目是不断的迭代演进的,那如何在一定时机的情况下自动触发或者手动触发更新文档,也是十分重要的一件事情。

明确了我们要解决的问题,剩下的事情就简单了。

生成工具

就直接使用 shell + Jazzy + SourceKitten 将上面步骤串联起来就可以了。Jazzy 之前介绍过了,一起看看 SourceKitten

SourceKitten

An adorable little framework and command line tool for interacting with SourceKit.

Sourcekitten 是基于 Apple 的 SourceKit 封装的命令行工具,SourceKitten 链接并与 sourcekitd.framework 通信以解析 Swift AST 树,最终提取 Swift 或 ObjC 文件的类结构和方法等。

SourceKit

SourceKit is a framework for supporting IDE features like indexing, syntax-coloring, code-completion, etc. In general it provides the infrastructure that an IDE needs for excellent language support.

文档索引页的生成

为了整体的样式统一,我们的索引页采用与 Jazzy 所生成的文档相同的 CSS 样式。由于 Jazzy 支持切换生成文档的主题,这里我们使用默认主题。

当我们访问静态网站时,入口一般都指向一个名为 index.html 的页面。 Jazzy 生成的入口也是 index.html

我们要做的就是往 index.html 内添加含对应的标签,并将标签链接指向各个依赖库的文档地址就可以了。

索引页

下面是我们需要修改的代码,完整的 index.html 模版可访问 Jazzy-template

<div class="content-wrapper">
    ...
    <article class="main-content">
    <section class="section">
        <div class="section-content top-matter">
            <h3 id='authors' class='heading'>业务库</h3>
        </div>
    </section>
    <section class="section">
        <div class="section-content">
        <div class="task-group">
            <ul class="item-container">
             <li>token-business</li>
            </ul>
        </div>
        </div>
    </section>
    <div class="section-content top-matter">
        <h3 id='authors' class='heading'>基础库</h3>
    </div>
    <section class="section">
        <div class="section-content">
        <div class="task-group">
            <ul class="item-container">
            <li>token-base</li>
            </ul>
        </div>
        </div>
    </section>
    </article>
</div>

要修改的是上面的 <li>token-*</lib> 元素,这里留的默认 token 是为了方便替换。

业务库

由于业务库逻辑一般会比较多,如果和基础库文档放一起,可能会导致生成文档的太大,Github Page 无法正常解析。因此,需要单独的文档仓库来存放文档。

基础库

基础库生成的文档会统一放到项目的 docs 目录下,同时 <li>token-base</li> 标签的地址最后会指向 docs/$lib_name/index.html 目录。

目前的结构是这样的:

WechatIMG10.png

文档结构

我们先来看一下以 Alamofire 项目生成的 docs 文档目录结构:

WechatIMG13.png
WechatIMG13.png

第一层包含了 ClassesEnumsExtensionsProtocolsStructs 等分类和对应的 index.html 索引文件。

第二层为具体到的每个 Class、Enum 或其他数据结构的 HTML 页面。如果该结构还存在嵌套的内部数据类型,会以递归的方式呈现。

整个 docs 的基础结构特别简单:

jazzy document.png
jazzy document.png

我们要做的就是复制上面的文件,以及修改的 index.html 就可以。

多依赖库的文档生成

对于 iOS 项目的依赖库管理标配为 CocoaPods (后面简称 Pod) ,它将所有的依赖库源码统一存放在项目的 /Pods 目录下。我们要做的就是遍历 /Pods 目录,逐一生成文档并将其输出到一个指定目录就可以了。

想法是美好的,现实是残酷的。在实际操作起来发现并没有那么简单。让我们开启踩坑之旅吧!

Swift 依赖库的文档生成

之前在 《Cocoa 代码注释与文档生成》 中介绍的 Swift 的文档生成都是基于该项目的 project 工程或者是 SwiftPM 配置来完成。好在 Pod 也为我们生成对应的 project,我们仅需通过 --build-tool-arguments 来指定 projecttarget 就可以了。

从零开始,我们先新建一个 Demo.xcodeproj 并配置如下 Podfile:

target 'Demo' do
 pod 'SnapKit'
 pod 'AFNetworking'
end

调用 Jazzy 生成 Swift 库 SnapKit 的文档:

$ bundle exec jazzy -o docs/SnapKit \
    --build-tool-arguments -project,Pods/Pods.xcodeproj,-target,SnapKit

通过 -o 将结果输出到 docs/SnapKit 目录下,执行后输出结果如下:

Running xcodebuild
Parsing Constraint.swift (1/34)
...
Parsing UILayoutSupport+Extensions.swift (34/34)
`ConstraintLayoutSupport` has no USR. ...
9% documentation coverage with 239 undocumented symbols
included 264 public or open symbols
skipped 81 private, fileprivate, or internal symbols (use `--min-acl` to specify a different minimum ACL)
building site
building search index
jam out ♪♫ to your fresh new docs in `docs/SnapKit`

可以看到 Jazzy 会遍历项目下的每个 swift 文件,对于项目中未引用的代码也会有提示。最后会输出代码的注释覆盖率,SnapKit 的覆盖率为 9%,有 239 个未注释的符号或变量。

指定文档的范围

需要注意的是,Jazzy 可以通过 --min-acl 来控制输出文档的范围。

  • 对于 Swift 项目,默认仅生成声明为 publicopen 的类、属性和方法等,如果想要输出私有变量的注释,还可以设置为 internalfileprivateprivate

  • 对于 ObjC 项目,Jazzy 仅会生成在 --umbrella-header 所指定的 header 文件中所引用的 .h 文件。

ObjC 依赖库的文档生成

相比 Swift,Objc 的依赖库需要多处理 umbrella header 的问题。先看 AFNetworking 的文档生成命令:

$ lib_name=AFNetworking
lib_path=$(pwd)/Pods/$lib_name
umbrella_header="$lib_path/$lib_name/$lib_name-umbrella.h"
sdk_path=`xcrun --show-sdk-path --sdk iphonesimulator`

bundle exec jazzy -o docs/$lib_name \
--objc \
--sdk iphoneos \
--build-tool-arguments \
--objc,$umbrella_header,--,-x,objective-c,-isysroot,$sdk_path,-I,$lib_path

第一个是需要指定 --objc,因为 Jazzy 默认解析 Swift 项目。

再来看 --build-tool-arguments 后跟的几个参数:

  • --objc <umbrella-header PATH>:这里的 --objc 是通知 SourceKitten 我要解析的是 Objc 的头文件,后面紧跟的为依赖库的 umbrella header
  • --:作为分割符,表示之后的参数会转发到 xcodebuildswift build
  • -x objective:通知 xcodebuildswift build 我要编译 ObjC 啦
  • -isysroot:指定所编译的 sdk,这里我们使用模拟器的 sdk
  • -I $lib_path:指定 include 的搜索路径

获取 umbrella header

在 ObjC 中引用代码是需要通过 #import 来完成的,而对于 ObjC 的 framework 而言,我们可以通过引入 umbrella header 来引入该 framework 暴露出来的全部 public header 文件。因此,可以理解为 umbrella header 是 ObjC framework 的 master header。具体可以看:讨论

这一点需要感谢 Pod,它为我们的依赖库统一生成了 A-umberlla.h 文件,存放在 Target Support Files/A/A-umberlla.h

在此之前很多依赖库的 umbrella header 并不是很规范。经常会有一些文件是 public 状态,却未添加到 umbrella header 中,导致无法直接通过 umbrella header 来完成引用。包括很多公司维护的私有库也会经常忘记更新 umbrella header 的情况,好在 Pod 帮我们自动生成了。

复制 umbrella header

细心的同学从 AFNetworking 的文档生成命令中能发现,AFNetworking-umbrella.h 的位置是在源码的文件夹下。如果直接指定为 Target Support Files 下的 umbrella header 文件是无法生成文档的。我们需要把它复制到源代码在同层目录下。

那么问题来了:如何正确的获取源码所在目录

首先想到的是和通过 .podspec 文件就能准确拿到 Source 目录。不过比较难实现,我们只能拿到的是 Local Podspecs 下的 .podspec 文件,否则需要在 pod install 时才能获取到。但是这么做需要修改 Podfile 也比较麻烦。

选择简单粗暴的方式,直接列出可能出现的 Source 路径:

# /A/Classes/...
# /A/src/a/...
# /A/A/Classe/... 
# /A/A/Classes/... 
# /A/A/Source/..
# /A/A/Sources/..
# /A/Source/A/...
# /A/Sources/A/... 
# /A/Source/...
# /A/A/..
# /A/...
# libextobjc/extobjc

有用 ClassesSourceSourcessrc 等等,情况五花八门,逐一匹配就可以了。

这么做是可以覆盖大部分的情况,但是仍然发现部分私有库生成的文档缺失甚至是空的。最终发现的问题是:clang 没有递归处理多级目录的文件,这里应该是参数没有正确设置,查看了 Clang 手册 感觉就是 -I 参数,不过也没有生效,有了解的同学求指点。

咋办,先暴力解决:

find $lib_path -type f ! -regex '*.\(h\|m\|swift\)' \
    ! -name '*.json' \
    ! -name '*.pdf' \
    -exec mv -i {} $lib_path \;

将子目录下文件全部移到 framework 源码目录下,再通过 Jazzy 来生成文档,算是暂时解决问题了。

然而 AFNetworking 的文档依旧不是完整的,不过属于另外一种情况。目录如下,大家可以 🤔 一下:

WechatIMG12.png
WechatIMG12.png

Swift & ObjC 混编依赖库的文档生成

对 Swift & ObjC 混编的依赖库本身是不提倡的,虽然在实际开发过程中无法避免。

为了测试混编库的文档生成,这里新建一个 Pod 库:Mixin,添加了 MixinSwift 和 MixinObjC 两个类:

MixinSwift

/// Test Swfit Class import Objective-C's property
public class MixinSwift: NSObject {

    /// say hello from Swift
    @objc public static let sayHi: String = "Hi, I'm from Swift"

    /// call Objective-C say Hi
    @objc public class func callObjC() {
        print("hello from MixinObjc: \(MixinObjC.sayHi)")
    }
}

MixinObjC

#import "MixinObjC.h"
#import <Mixin/Mixin-Swift.h>
@implementation MixinObjC
+ (NSString *)sayHi {
    return @"Hi, I'm from Objective-C";
}
+ (void)callSwift {
    NSLog(@"hello from MixinSwift: %@", MixinSwift.sayHi);
}
@end

由于 Jazzy 无法直接生成混编项目的文档,这里需要通过 SourceKitten 分别将 Swift 和 ObjC 的代码注释转成 json 的中间格式,才能生存完整的文档。生成命令如下:

lib_name=Mixin
output="public/docs/$lib_name"
swift_doc="$output/$lib_name-swift-doc.json"
objc_doc="$output/$lib_name-objc-doc.json"

lib_path=$(pwd)/Pods/$lib_name/$lib_name/Classes
umbrella_header="$lib_path/$lib_name-umbrella.h"
sdk_path=`xcrun --show-sdk-path --sdk iphonesimulator`

sourcekitten doc --objc $umbrella_header \
      -- -x objective-c -isysroot $sdk_path \
      -I $lib_path \
      -fmodules > $objc_doc

sourcekitten doc -- -project Pods/Pods.xcodeproj -target Mixin > $swift_doc

jazzy -o $output --sourcekitten-sourcefile $swift_doc,$objc_doc 

文档如下:

WechatIMG13.png
WechatIMG13.png

依赖库类型判断

由于不同类型的依赖库,其生成文档的脚本有所不同,我们还需要判断每个依赖库类型,是纯 ObjC、纯 Swift 还是混编类型。解决方式就是对 Source 目录下的文件类型进行 count 以判断依赖库类型:

swift_count=`find $lib_path -maxdepth 6 -type f  -name '*.swift' | wc -l`
objc_count=`find $lib_path -maxdepth 6 -type f  -name '*.m' | wc -l`

# file state, 0: only objc, 1: only swift, 2: swift & objc
lib_state=0
if [[ $swift_count -ge 1 && $objc_count -ge 1 ]]; then 
    lib_state=2
elif [[ $swift_count -eq 0 && $objc_count -ge 1 ]]; then 
    lib_state=0
elif [[ $swift_count -ge 1 && $objc_count -eq 0 ]]; then 
    lib_state=1
fi

静态文档的部署

我们使用是 Github Page 来进行文档部署,特别简单仅需在 repo 的设置页指定文档类型就可以了。剩下的就是提交代码,Git 会自动触发编译。

WechatIMG14.png
WechatIMG14.png

更多介绍请查看 Github Page 说明

最后,完整 Demo 的托管地址为:Cocoa-Documentation-Example

Git page 文档地址:https://looseyi.github.io/Cocoa-Documentation-Example,这个地址是 Github 自动生成的。效果如下:

WechatIMG15.png
WechatIMG15.png

One More Thing ...

尽管我们当前的方案可以正确的生成文档,但是其实还可以更进一步。

当前的文档生成是基于 project 的方式,而我们完全可以针对每一个文件生成一份 json 数据,最后在把它们全部粘一起。命令的话 SourceKitten 都准备好了:

Swift 文件解析

$ sourcekitten doc --single-file $input_file -- -j4 $input_file >> $temp_outout

ObjC .h 文件解析

$ sourcekitten doc --objc \
   --single-file $input_file \
   -- -x objective-c \
   -isysroot $sdk_path \
   -I $lib_path -fmodules >> $temp_outout

通过这种方式,既不不需要配置 project 判断依赖库类型,也省去了查找找 umbrella header 的麻烦。

完整脚本传送门:docs_deploy.sh

总结

  • 多依赖库的文档生成还是比较简单的,感觉最难的还是读懂 Jazzy + SourceKitten 的文档和参数的配置。
  • 思路是充分利用了 CocoaPods 为我们搭好的环境,在其之上就可以轻松生成文档,主题可定制哦。
  • 倒腾过个人博客的同学,对于 Github Page 和文档的部署应该很熟悉,免费的 Github 资源还是要充分利用的。

知识点问题梳理

这里罗列了四个问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入**收藏 **再次阅读:

  1. Jazzy 对 API 的控制范围有几种选择?
  2. 对于文中所采用的判断依赖库语言类型的方法是什么,还有更好的方式吗?
  3. ObjC 的 umbrella header 是从哪里获取的?
  4. 扩展: SourceKitten 所生成的 JSON 结构包括哪些字段?