本文转载自内部同事分享linkzhong(钟亮)
(相关资料图)
发表时间 2022年12月07日
导语:Xcode 作为 iOS 开发绕不开的 IDE 代码编辑功能很强大,但是在编辑大型工程时总是遇到代码高亮、代码提示失效,建立代码索引慢等问题。本文抽丝剥茧,介绍了 Xcode 代码索引的工作原理,并提出了一种跨设备共享代码索引的方案,在企微落地后优化了90%的全量索引耗时。
Xcode 作为 iOS 开发绕不开的 IDE,深受大家的“喜爱”,作为一款成熟的 IDE,大家对于它的期待还是挺高的。
Xcode 在面对体量巨大的工程时还是显得力不从心,你可能也有以下困惑:
正在修紧急 bug,Xcode 代码高亮怎么没了?代码提示、代码跳转统统失效,关键时刻掉链子;面对海量代码,Xcode 的 Open Quickly 功能能够通过关键词迅速定位到想要找到的代码,背后原理究竟是什么?代码索引总是耗时很长,在后台占用大量CPU,能不能提前预生成索引数据,跨设备共享。带着上面的问题,笔者阅读了并整理了网上可以找到的相关资料,然后进行了大量的实验,最后完成了本文。本文基于 Xcode 14.0 (14A309) 进行研究(各个版本 Xcode 构建索引策略可能有所差异,但是思路是大体一致的),如有错误或者遗漏之处望各位大佬指正。
Xcode 的代码高亮、代码补全、代码跳转、查找调用链、重构、Open Quickly 等功能都是 Xcode Index 的一部分,打开 Xcode 工程可以在顶部 bar 看到 Index 的进度信息。
Xcode Index 是如何工作的呢?这就要引入一个新的工具 SourceKit,上述的 Xcode 代码操作相关功能,都是基于 SourceKit 实现的。SourceKit 和 Xcode 通过 XPC 进行通信,SourceKit 是 Xcode 代码索引功能的幕后主角,Xcode 是客户端,负责收集用户操作,转换成请求发给 SourceKit,最后展示计算结果;SourceKit 是后端,负责生成索引数据,计算 Xcode 请求。
整个工作流程如下图所示,Xcode 是前端,SourceKit 是驱动引擎,Clang 是实际产生索引数据的,索引数据存储在 Index Store。
Xcode 生成 Index Store 有两条路径:
路径一、Xcode 在闲时自动调用 SourceKit 在后台生成数据。SourceKit 最终调用 Clang 生成数据,使用编译参数 -index-store-path -fsyntax-only
,生成 Index 数据只需完成语法分析即可得到结果,不需要进行完整编译流程。
路径二、开启 Index-While-Building
,如果将该配置项打开,会在编译过程中新增参数 -index-store-path
,在编译时同时生成 Index 数据,由于编译时本来就需要进行词法分析、语法分析,因此中间产物是可以复用的。开启该功能会对编译速度产生影响,官方给出的数据是慢 2-5%。
了解了整个工作流程,接下来我们来讲讲 SourceKit 的一些工作细节。运行 Xcode 时在活动监视器里可以看到一个进程 com.apple.dt.SKAgent
,SKAgent 是 SourceKit 的 XPC 服务,负责和Xcode 进行通信,它的路径是:/Applications/Xcode.app/Contents/SharedFrameworks/SourceKit.framework/Versions/A/XPCServices/com.apple.dt.SKAgent.xpc/Contents/MacOS/com.apple.dt.SKAgent。
为了进一步探索 SourceKit 在背后究竟做了什么,我们将 Xcode 和 SourceKit 通信日志打印出来分析,通过以下命令启动 Xcode,可以将日志打印到指定文件。
SOURCEKIT_LOGGING=3 /Applications/Xcode.app/Contents/MacOS/Xcode &> ~/Downloads/xcode.log
SourceKit 支持哪些命令可以查看这个文件:
/Xcode.app/Contents/SharedFrameworks/SourceKit.framework/Versions/A/XPCServices/com.apple.dt.SKAgent.xpc/Contents/Frameworks/SKIPC.dylib
索引进度更新相关的命令主要有以下几条:
更新索引进度:indexer.callback.on-is-indexing-workspace索引建立完毕:indexer.callback.on-did-index-workspace建立索引任务中断:indexer.callback.on-did-suspend-indexing-workspace建立索引任务恢复:indexer.callback.on-did-resume-indexing-workspace下面是一个实际案例,命令为 indexer.callback.on-is-indexing-workspace
,在 key.indexer.callback.on-is-indexing-workspace.user-info
的 XML 结构中,包含了已经完成索引文件数量 :IDEIndexingFilesCompletedKey
、索引剩余文件数量:IDEIndexingFilesRemainingKey
等信息,Xcode 根据进度信息更新 UI 进度。
SourceKit-client: [2:notification:66619:169.3069] { key.notification: indexer.callback, key.indexer.arg.indexer-token: 1, key.indexer.callback.kind: indexer.callback.on-is-indexing-workspace, key.indexer.callback.on-is-indexing-workspace.user-info: "\n\n\n\n\tIDEIndexingFilesCompletedKey \n\t8740 \n\tIDEIndexingFilesRemainingKey \n\t8262 \n\tIDEIndexingHotFilesKey \n\t0 \n\tIDEIndexingLoadingProgressKey \n\t0.0 \n\tIDEIndexingQueueWidthKey \n\t8 \n\tIDEIndexingWaitingForPrebuildKey \n\t \n \n \n"}
接下来的案例场景是打开文件 ViewController.mm 编辑,首先 Xcode 会发送命令 source.request.indexer.editor-moved-focus-to-file
告诉 SourceKit 用户正在编辑 ViewController.mm,优先响应该文件的请求。
SourceKit-client: [2:request:259:29.8311] [78] { key.request: source.request.indexer.editor-moved-focus-to-file, key.indexer.arg.indexer-token: 1, key.indexer.arg.occurrence.file: "/Users/link/Desktop/Demo/src/ViewController.mm"}
然后 Xcode 会发送命令 source.request.document.symbol-occurrences
,获取当前文件的所有符号信息,包含符号名、符号类型、语言、代码行列等信息,Xcode 通过这些信息进行代码高亮。
SourceKit-client: [2:request:259:29.9688] [80] { key.request: source.request.document.symbol-occurrences, key.indexer.arg.indexer-token: 1, key.indexer.arg.query.doc-location: { key.indexer.arg.doc-loc.url: "file:///Users/link/Desktop/Demo/src/ViewController.mm", key.indexer.arg.doc-loc.start-line: 18, key.indexer.arg.doc-loc.start-col: 0, key.indexer.arg.doc-loc.end-line: 33, key.indexer.arg.doc-loc.end-col: 5, key.indexer.arg.doc-loc.range-loc: 233, key.indexer.arg.doc-loc.range-count: 324, key.indexer.arg.doc-loc.encoding: 0 }, key.indexer.arg.query.file-content: ""}SourceKit-client: [2:response:75571:29.9700] [80] { key.symbols: [ { key.symbol: { key.indexer.arg.symbol.name: "viewDidLoad", key.indexer.arg.symbol.kind: "Xcode.SourceCodeSymbolKind.InstanceMethod", key.indexer.arg.symbol.language: "Xcode.SourceCodeLanguage.Objective-C", key.indexer.arg.symbol.resolution: "c:objc(cs)UIViewController(im)viewDidLoad" }, key.indexer.arg.occurrence.role: 5, key.indexer.arg.occurrence.location: { key.indexer.arg.doc-loc.url: "file:///Users/link/Desktop/Demo/src/ViewController.mm", key.indexer.arg.doc-loc.start-line: 18, key.indexer.arg.doc-loc.start-col: 5, key.indexer.arg.doc-loc.end-line: 18, key.indexer.arg.doc-loc.end-col: 10, key.indexer.arg.doc-loc.range-loc: 9223372036854775807, key.indexer.arg.doc-loc.range-count: 0, key.indexer.arg.doc-loc.encoding: 1 }, key.indexer.arg.occurrence.line: 0, key.indexer.arg.occurrence.col: 0, key.indexer.arg.occurrence.file: "/Users/link/Desktop/Demo/src/ViewController.mm", key.indexer.arg.symbol.display-name: "-viewDidLoad", key.indexer.arg.symbol.is-in-project: false, key.indexer.arg.symbol.is-virtual: true, key.indexer.arg.symbol.is-system: true, key.is-implicit: false }, ... ]}
接下来我们看看 Index Store 是怎么存储的,如下图所示,主要由两个目录,DataStore(records + units)、UniDB。DataStore 存储了 Clang 编译的产物,是索引原始数据,UniDB 是为了加速查询建立的表,存储了经过处理后的信息。
Index Store 存储路径Xcode14:~/Library/Developer/Xcode/DerivedData/project-xxx/Index.noindexXcode13:~/Library/Developer/Xcode/DerivedData/project-xxx/Index
units 记录了源码文件的路径、依赖的文件路径、依赖的 records 文件的路径,它的命名规则是 test.o-hash
(Hash of output file path),如果文件名、路径等不变化文件名则不会变化。
records 记录了每个源码文件由哪些符号构成,它主要由 Symbol、Occurence 两部分构成。它的命名规则是 test.m-hash
(Hash of output file path),如果代码变更文件名就会变化。
我们可以借助 LLVM 的工具 c-index-test
打印它们的数据结构:
# 打印 unit 数据结构c-index-test core --print-unit /path/DataStore/v5/units/ModelA.o-2D1ATXD2A198H# 打印 record 数据结构c-index-test core --print-record /records/D4/ModelA.m-1S1A5O0O7K4D4
接下来看一个实际的案例,有一个源码文件 ModelA.mm,代码如下:
#import "ModelA.h"@implementation ModelA- (void)sayHello { [super sayHello]; NSLog(@"ModelA %@", self.name);}@end
Unit 数据结构如下:
provider: clang-1400.0.29.102is-system: 0is-module: 0module-name: has-main: 1main-path: /path/Demo2/src/ModelA.mwork-dir: /path/Demo2out-file: /SourceKitDemo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/ModelA.otarget: arm64-apple-ios13.0.0is-debug: 1DEPEND STARTUnit | system | Foundation | /Users/link/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/1SU4CTGFJJUAV/Foundation-A3SOD99KJ0S9.pcm | Foundation-A3SOD99KJ0S9.pcm-BVRSB7PKB109Record | user | /path/Demo2/src/ModelA.m | ModelA.m-1S1A5O0O7K4D4Record | user | /path/Demo2/src/ModelA.h | ModelA.h-1F980T5AVPZPPRecord | user | /path/Demo2/src/BaseModel.h | BaseModel.h-UWC84R5719GYFile | system | /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.0.sdk/System/Library/Frameworks/Foundation.framework/Modules/module.modulemapFile | system | ...省略部分DEPEND END (40)INCLUDE START/path/Demo2/src/ModelA.m:8 | /path/Demo2/src/ModelA.h/path/Demo2/src/ModelA.h:9 | /path/Demo2/src/BaseModel.hINCLUDE END (2)
Record 数据结构如下:
class/ObjC | ModelA | c:objc(cs)ModelA | | Def - RelChildinstance-method/ObjC | sayHello | c:objc(cs)ModelA(im)sayHello | | Def,Dyn,RelChild,RelOver - RelCall,RelContinstance-method/ObjC | sayHello | c:objc(cs)BaseModel(im)sayHello | | Ref,Call,RelCall,RelCont - RelOverfunction/C | NSLog | c:@F@NSLog | | Ref,Call,RelCall,RelCont - instance-property/ObjC | name | c:objc(cs)BaseModel(py)name | | Ref,RelCont - instance-method/acc-get/ObjC | name | c:objc(cs)BaseModel(im)name | | Ref,Call,Dyn,Impl,RelRec,RelCall,RelCont - class/ObjC | ModelA | c:objc(cs)ModelA | | - RelRec------------10:17 | class/ObjC | c:objc(cs)ModelA | Def | rel: 012:9 | instance-method/ObjC | c:objc(cs)ModelA(im)sayHello | Def,Dyn,RelChild,RelOver | rel: 2RelChild | c:objc(cs)ModelARelOver | c:objc(cs)BaseModel(im)sayHello13:12 | instance-method/ObjC | c:objc(cs)BaseModel(im)sayHello | Ref,Call,RelCall,RelCont | rel: 1RelCall,RelCont | c:objc(cs)ModelA(im)sayHello14:5 | function/C | c:@F@NSLog | Ref,Call,RelCall,RelCont | rel: 1RelCall,RelCont | c:objc(cs)ModelA(im)sayHello14:30 | instance-property/ObjC | c:objc(cs)BaseModel(py)name | Ref,RelCont | rel: 1RelCont | c:objc(cs)ModelA(im)sayHello14:30 | instance-method/acc-get/ObjC | c:objc(cs)BaseModel(im)name | Ref,Call,Dyn,Impl,RelRec,RelCall,RelCont | rel: 2RelCall,RelCont | c:objc(cs)ModelA(im)sayHelloRelRec | c:objc(cs)ModelA
下图通过一个案例来展示 Unit 与 Record 之间的关系,有两个源码文件,first.c 依赖了 things.h、header.h、feature.h。second.c 依赖了 header.h、feature.h。在 things.h 定义了一个宏,header.h 会判断是否定义宏展开部分代码。 建立索引完成后,会生成 2 个 Unit 和 6 个 Record 文件,由于编译 first.o、second.o 时宏定义不一样,导致 header.h 展开内容不一样,所以会产生两份 header.h。
Record 主要由 Symbol、Occurence 两部分构成,Symbol 由 USR、Language、Kind 等元素构成,每个符号对应一个 Symbol。Occurence 由 Symbol、Location、Roles、Relations 等元素构成,它表示了每个 Symbol 被使用的位置。
下图展示了一个案例,1 到 12 行定义了类 Polygon
,14 到 26 行定义了 Polygon
的子类 RegularPolygon
,
Record 是怎么表示类定义和子类继承关系的呢?首先图中所示的两个 Symbol,Polygon、RegularPolygon 分别为两个类的符号信息,Symbol 通过 USR(Unified Symbol Resolution)来唯一标识,USR 的官方描述:
USR: A Unified Symbol Resolution is a string that identifies a particular entity (function, class, variable, etc.) within a program. USRs can be compared across translation units to determine, e.g., when references in one translation refer to an entity defined in another translation unit.
这两个 Symbol 一共出现了 3 次,对应 3 个 Occurrence,其中 Polygon 出现了两次,一次是出现在 1:7 位置,角色是类定义,第二次出现在 14:31,角色是被继承;RegularPolygon 出现了一次位置在 14:7,角色是类定义。
了解了 Unit 和 Record 的数据结构和用途,我们就可以推导出 Xcode 实现一些功能的原理,例如有这么一个场景,我们需要找到 Polygon 的所有子类,可以这么实现:
遍历所有 Record 的 Occurrence,找到 Roles 为 RelationBaseOf 对应 Symbol 是 Polygon 的 Occurrence;通过步骤一找到的 Occurrence 就可以找到所有定义 Polygon 子类的 Occurrence,从而找到 Polygon 子类的 Symbol;最后结合 Unit 可以定位到我们要找的子类的行号、列号;但是线性遍历的效率较低,Xcode 为了优化查询效率引入了 LMDB 来存储中间数据结构。LMDB 全称为 Lightning Memory-Mapped Database,是高性能的内存映射型数据库,它有以下优点:
数据读写速度快,基于内存映射方式访问文件;使用轻量,文件结构简单,包含一个数据文件和一个锁文件,数据随意复制,读写引用代码很小的 LMDB 库即可完成它由两个文件组成:data.mdb、lock.mdb,为了探索 Xcode 在 LMDB 里存储了什么数据,我们可以用 python 的 lmdb 库解析。
import lmdbif __name__ == "__main__":env = lmdb.open("path", max_dbs=14) txn = env.begin() for key, value in txn.cursor(): print(key, value)
解析结果如下图所示,可以看到它由多个表构成,很多表下还有子表。
还是用刚刚的案例:查询 Polygon 的所有子类,Xcode 通过下面的 key-value 表来加速查询过程:
建立一个 key-value 表,key 是 USR,value 是 USR 出现过的 Record、Roles;计算 Polygon 的 USR,通过 USR 查找到它出现过的 Records,筛选 Roles 包含 RelationBaseOf,定位到哪些 Record 内包含 Polygon 子类定义;在 Record 文件中可以查询具体子类的信息;还有一些其它用的表:
Search symbols by name:记录了 Symbol Name 和 USR 的对应关系,方便通过关键词搜索代码,Open Quickly 就是基于该表实现的;Remove data from unreferenced records:记录了 Record 文件被哪些 Unit 使用,如果没有 Unit 使用了,该 Record 就会被清理;Units to re-index when header changes:记录了头文件被哪些 unit 使用,用于查询头文件变化后,哪些 unit 需要重新生成索引数据;了解 Index Store 的数据结构之后,不难发现只要源码、编译选项一致,产生的 Record 其实是一样的,企微工程完整进行一次代码索引耗时 24 分钟,我们是否可以提前预生成 Unit、Record,开发直接下载产物加速代码索引。
我们先用一个 Demo 工程来验证我们的猜想,工程很简单,结构如下所示:
我们将同样的工程拷贝两份,分别为:Demo1、Demo2,最终目标是在 Demo1 工程可以复用 Demo2 工程生成的 Index Store。
首先在 Demo2 的 Other C Flags
配置编译参数 -index-store-path /path/DataStore
,在编译时生成 DataStore 数据到指定目录。
首先删除 Demo1 的 DataStore、UniDB 目录,将 Demo2 产生的 DataStore 拷贝到 Demo1 的 DerivedData 目录
DataStore 存放路径:~/Library/Developer/Xcode/DerivedData/Demo1-xxx/Index.noindex
在命令行输入以下命令打开 Xcode Index 日志,可以确认 Xcode 对哪些文件进行了索引。
defaults write com.apple.dt.Xcode IDEIndexShowLog -bool YES
打开 Demo1 工程,观察日志发现还是会重新建立索引,说明复用失败。查看 Demo2 Unit 信息,可以看到它存储了 Demo2 工程的绝对路径信息,要替换成 Demo1 工程的路径。
provider: clang-1400.0.29.102is-system: 0is-module: 0module-name: has-main: 1main-path: /path/Demo2/src/ModelA.mwork-dir: /path/Demo2...
可以使用 index-import
工具(https://github.com/MobileNativeFoundation/index-import)来完成替换操作。
index-import \ -remap "/path/Demo2=/path/Demo" \ "/path/DataStore" \ "/path/DataStore2"
替换后再次查看 unit 信息,可以看到路径已经被修改。再次替换 Demo1 工程 DataStore,发现复用成功。
provider: clang-1400.0.29.102is-system: 0is-module: 0module-name: has-main: 1main-path: /path/Demo1/src/ModelA.mwork-dir: /path/Demo1
在 Demo 工程我们验证了方案可行,于是想通过这种方式提升开发本地索引效率,要让方案顺利落地,需要让整个流程自动化,并且让开发同学使用尽量简单,最终我们落地的流程如下图所示:
在流水线上使用构建机自动构建最新代码的索引,构建完成后上传到存储服务;开发在本机触发更新索引,从存储服务下载最新的索引数据;清理历史索引数据,进行 remap 操作,将路径修改为本地路径,然后替换 DerivedData 的 DataStore;经过测试,在 M1 Max 机器上,使用索引数据缓存后,企微工程建立全量索引耗时从 24 分钟优化到了 1.5 分钟。
参考资料
https://www.youtube.com/watch?v=jGJhnIT-D2Mhttps://github.com/bazel-ios/rules_ios/blob/master/docs/index_while_building.mdhttps://github.com/MobileNativeFoundation/index-importhttps://levelup.gitconnected.com/uncovering-xcode-indexing-8b3f3ff82551X 关闭