本文转载自内部同事分享linkzhong(钟亮)
发表时间 2022年12月07日
导语:Xcode 作为 iOS 开发绕不开的 IDE 代码编辑功能很强大,但是在编辑大型工程时总是遇到代码高亮、代码提示失效,建立代码索引慢等问题。本文抽丝剥茧,介绍了 Xcode 代码索引的工作原理,并提出了一种跨设备共享代码索引的方案,在企微落地后优化了90%的全量索引耗时。
Xcode 作为 iOS 开发绕不开的 IDE,深受大家的“喜爱”,作为一款成熟的 IDE,大家对于它的期待还是挺高的。
Xcode 在面对体量巨大的工程时还是显得力不从心,你可能也有以下困惑:
带着上面的问题,笔者阅读了并整理了网上可以找到的相关资料,然后进行了大量的实验,最后完成了本文。本文基于 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
,在 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: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEIndexingFilesCompletedKey</key>\n\t<integer>8740</integer>\n\t<key>IDEIndexingFilesRemainingKey</key>\n\t<integer>8262</integer>\n\t<key>IDEIndexingHotFilesKey</key>\n\t<integer>0</integer>\n\t<key>IDEIndexingLoadingProgressKey</key>\n\t<real>0.0</real>\n\t<key>IDEIndexingQueueWidthKey</key>\n\t<integer>8</integer>\n\t<key>IDEIndexingWaitingForPrebuildKey</key>\n\t<false/>\n</dict>\n</plist>\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: "<DATA>"
}
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.noindex Xcode13:~/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.102
is-system: 0
is-module: 0
module-name: <none>
has-main: 1
main-path: /path/Demo2/src/ModelA.m
work-dir: /path/Demo2
out-file: /SourceKitDemo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/ModelA.o
target: arm64-apple-ios13.0.0
is-debug: 1
DEPEND START
Unit | system | Foundation | /Users/link/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/1SU4CTGFJJUAV/Foundation-A3SOD99KJ0S9.pcm | Foundation-A3SOD99KJ0S9.pcm-BVRSB7PKB109
Record | user | /path/Demo2/src/ModelA.m | ModelA.m-1S1A5O0O7K4D4
Record | user | /path/Demo2/src/ModelA.h | ModelA.h-1F980T5AVPZPP
Record | user | /path/Demo2/src/BaseModel.h | BaseModel.h-UWC84R5719GY
File | system | /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.0.sdk/System/Library/Frameworks/Foundation.framework/Modules/module.modulemap
File | 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.h
INCLUDE END (2)
Record 数据结构如下:
class/ObjC | ModelA | c:objc(cs)ModelA | <no-cgname> | Def - RelChild
instance-method/ObjC | sayHello | c:objc(cs)ModelA(im)sayHello | <no-cgname> | Def,Dyn,RelChild,RelOver - RelCall,RelCont
instance-method/ObjC | sayHello | c:objc(cs)BaseModel(im)sayHello | <no-cgname> | Ref,Call,RelCall,RelCont - RelOver
function/C | NSLog | c:@F@NSLog | <no-cgname> | Ref,Call,RelCall,RelCont -
instance-property/ObjC | name | c:objc(cs)BaseModel(py)name | <no-cgname> | Ref,RelCont -
instance-method/acc-get/ObjC | name | c:objc(cs)BaseModel(im)name | <no-cgname> | Ref,Call,Dyn,Impl,RelRec,RelCall,RelCont -
class/ObjC | ModelA | c:objc(cs)ModelA | <no-cgname> | - RelRec
------------
10:17 | class/ObjC | c:objc(cs)ModelA | Def | rel: 0
12:9 | instance-method/ObjC | c:objc(cs)ModelA(im)sayHello | Def,Dyn,RelChild,RelOver | rel: 2
RelChild | c:objc(cs)ModelA
RelOver | c:objc(cs)BaseModel(im)sayHello
13:12 | instance-method/ObjC | c:objc(cs)BaseModel(im)sayHello | Ref,Call,RelCall,RelCont | rel: 1
RelCall,RelCont | c:objc(cs)ModelA(im)sayHello
14:5 | function/C | c:@F@NSLog | Ref,Call,RelCall,RelCont | rel: 1
RelCall,RelCont | c:objc(cs)ModelA(im)sayHello
14:30 | instance-property/ObjC | c:objc(cs)BaseModel(py)name | Ref,RelCont | rel: 1
RelCont | c:objc(cs)ModelA(im)sayHello
14:30 | instance-method/acc-get/ObjC | c:objc(cs)BaseModel(im)name | Ref,Call,Dyn,Impl,RelRec,RelCall,RelCont | rel: 2
RelCall,RelCont | c:objc(cs)ModelA(im)sayHello
RelRec | 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 的所有子类,可以这么实现:
但是线性遍历的效率较低,Xcode 为了优化查询效率引入了 LMDB 来存储中间数据结构。LMDB 全称为 Lightning Memory-Mapped Database,是高性能的内存映射型数据库,它有以下优点:
它由两个文件组成:data.mdb、lock.mdb,为了探索 Xcode 在 LMDB 里存储了什么数据,我们可以用 python 的 lmdb 库解析。
import lmdb
if __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 表来加速查询过程:
还有一些其它用的表:
了解 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.102
is-system: 0
is-module: 0
module-name: <none>
has-main: 1
main-path: /path/Demo2/src/ModelA.m
work-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.102
is-system: 0
is-module: 0
module-name: <none>
has-main: 1
main-path: /path/Demo1/src/ModelA.m
work-dir: /path/Demo1
在 Demo 工程我们验证了方案可行,于是想通过这种方式提升开发本地索引效率,要让方案顺利落地,需要让整个流程自动化,并且让开发同学使用尽量简单,最终我们落地的流程如下图所示:
经过测试,在 M1 Max 机器上,使用索引数据缓存后,企微工程建立全量索引耗时从 24 分钟优化到了 1.5 分钟。
参考资料
原创声明,本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。