1.前言:
鸿蒙程序分析框架ArkAnalyzer(方舟分析器)
源码地址
入门文档
2.阅读入门文档后:
本人具有一定的Java开发经验。虽然我对 TypeScript(TS)和 ArkTS 还不熟,但很多概念对我这个 Java 开发者来说并不陌生,反而有种亲切感。
在我看来,ArkAnalyzer 本质上就是一个针对 ArkTS 语言的静态代码分析框架。这让我想起了我在 Java 世界里常用的工具:
1.Soot 或 ASM:这些是用来分析和修改 Java 字节码的框架。ArkAnalyzer 似乎在做类似的事情,不过它操作的是更高层次的 ArkTS 源码。
Java Compiler API (JSR 199):这个 API 允许你在 Java 程序里调用 Java 编译器,并访问 AST(抽象语法树)。ArkAnalyzer 的第一步就是生成 AST,这完全是同一个思路。
Checkstyle:这些是用于代码质量和安全扫描的工具。它们底层也依赖于静态分析。ArkAnalyzer 提供了构建这类高级工具所需的基础能力。
第二步:运行第一个基本示例 (第3.1章)
环境搭好了,我就要开始写代码了。照着 ex01 的例子来:
准备被分析的项目:我创建一个 demo_project 文件夹,并把 books.ts, bookService.ts, index.ts 这几个文件按结构放好。这个项目很简单,就是一个图书管理的模型和服务,非常典型的 Java 示例风格。
编写分析脚本 basicUsage.ts:
import { SceneConfig, Scene } from 'arkanalyzer';:导入依赖,和 Java 的 import 一样。
const projectRoot = 'tests';:指定要分析的项目路径。
let config = new SceneConfig();
config.buildFromProjectDir(projectRoot);:这两步是配置分析器。
let scene = new Scene();
scene.buildSceneFromProjectDir(config);:这两步是执行分析,构建出 Scene 这个大对象。
然后,我开始我的探索
scene.getFiles():获取所有文件,打印文件名。
scene.getClasses():获取所有类,打印类名。这里我注意到一个细节:输出了好几个 _DEFAULT_ARK_CLASS。文档解释说每个文件和命名空间都有一个默认类。这和 Java 不太一样,Java 的顶层只能是类、接口或枚举。TS 似乎更灵活,可以在文件顶层直接写函数或变量,ArkAnalyzer 把这些“游离”的代码放进一个默认类里,这个设计很合理。
scene.getMethods():获取所有方法。同样,我也看到了 _DEFAULT_ARK_METHOD。
我尝试链式调用,比如 scene.getFiles()[0].getClasses()[0].getMethods(),来感受一下这个 API 的设计。
第三步:深入探索 CFG 和调用图 (第3.2章)
基本 API 熟悉后,我对更高级的功能感兴趣。
获取 CFG (ex01.6):
我找到 getBooksByAuthor 这个方法,然后调用 getBody().getCfg()。
我最感兴趣的是 DotMethodPrinter。能把 CFG 导出成 .dot 文件太棒了!我知道 Graphviz 这个工具,它可以把 .dot 文件渲染成图片。这样我就能直观地看到那个 for 循环和 if 判断构成的流程图了。这对于理解复杂方法的逻辑非常有帮助。
生成调用图 (ex02):
这部分提到了 CHA, RTA, PTA 三种算法,这对我来说是新知识,但概念不难理解。
CHA (Class Hierarchy Analysis):基于类的继承关系来分析。比如 animal.sound(),如果 animal 的静态类型是 Animal,那么 CHA 会认为 Dog.sound(), Cat.sound(), Pig.sound() 都可能被调用。这是一种最快但最不精确的算法。
RTA (Rapid Type Analysis):在 CHA 的基础上,会去看代码里到底 new 了哪些类的实例。如果代码里只 new Cat() 和 new Dog(),那 RTA 就不会把 Pig.sound() 加到调用图里。比 CHA 精确。
PTA (Points-to Analysis):指针分析,最精确也最慢。它会去追踪每个变量可能指向哪些具体的对象实例。在 makeSound(new Dog()) 这个调用里,PTA 能够精确地知道传入 makeSound 函数的 animal 参数指向的就是一个 Dog 对象,所以 animal.sound() 只会调用 Dog.sound()。
我把三种算法都跑一遍,对比它们的差异。
PTA算法简单分析:https://blog.csdn.net/2302_80118884/article/details/151649501?spm=1001.2014.3001.5501
一个例子:
// --- 基础类 ---
abstract class Animal { abstract sound(): void; }
class Dog extends Animal { sound() { /* 汪汪 */ } }
class Cat extends Animal { sound() { /* 喵喵 */ } }
class Pig extends Animal { sound() { /* 哼哼 */ } }
class PetStore {
bestSeller: Animal | null = null;
inventory: Animal[] = [];
setBestSeller(pet: Animal) {
this.bestSeller = pet;
}
stockInventory(pets: Animal[]) {
this.inventory = pets;
}
promoteBestSeller() {
if (this.bestSeller) {
this.bestSeller.sound();
}
}
}
function main1() {
const store = new PetStore();
const myDog = new Dog();
const myCat = new Cat();
// 1. 将 Dog 实例设置到 store 的字段中
store.setBestSeller(myDog);
// 2. 调用一个方法,该方法会使用这个字段
store.promoteBestSeller();
}
我可能会遇到的问题和想进一步了解的
ArkUI 和 ViewTree:这是我完全陌生的领域。@State, @Prop 这种装饰器看起来像是某种数据绑定机制,类似于前端框架(React, Vue)或 Android Jetpack Compose。ViewTree 显然是用来分析 UI 结构的。作为一个后端 Java 开发者,这部分对我来说很新奇。我会好奇这个 ViewTree 是如何从代码中构建出来的,以及它能用来做什么样的 UI 分析(比如,查找所有未绑定的 UI 控件?分析页面跳转逻辑?)。
数据流分析的深度:ex05.1 空指针检测 很有用,这在 Java 里就是 NullPointerException 分析。我想知道 ArkAnalyzer 的数据流分析能力有多强?它能处理多复杂的场景?比如跨文件、跨模块的污点分析(Taint Analysis),即追踪一个用户输入(可能是恶意的)在系统中的流向,最终是否被未经验证地执行。
可扩展性:我能自定义规则吗?比如,我想写一个检查器,规定我们项目中所有的 Service 类都必须以 Service 结尾。我可以通过 Scene API 遍历所有类,然后检查类名来实现。这个看起来很简单。但如果我想写一个更复杂的规则,比如“所有从数据库读取的数据,在返回给前端前必须经过一个特定的脱敏函数处理”,这就需要用到数据流分析了。ArkAnalyzer 是否提供了方便的 API 来让我构建这种自定义的、基于数据流的检查器?
从文档脚本分析ts源码:
import { SceneConfig, Scene} from 'arkanalyzer';
const projectRoot = 'tests';
let config: SceneConfig = new SceneConfig();
//1
config.buildFromProjectDir(projectRoot);
let scene: Scene = new Scene();
//2
scene.buildSceneFromProjectDir(config);
1.调用 config.buildFromProjectDir('tests') 时,SceneConfig 对象内部主要完成了 三件核心任务,这是一个为后续分析进行“信息采集”和“环境设置”的关键步骤。
3.buildFromProjectDir 函数源码
// src/SceneConfig.ts
public buildFromProjectDir(targetProjectDirectory: string): void {
// 任务1:记录项目的根目录路径
this.targetProjectDirectory = targetProjectDirectory;
// 任务2:根据目录路径推断项目名称
this.targetProjectName = path.basename(targetProjectDirectory);
// 任务3:扫描并收集项目下所有的源文件
this.projectFiles = getAllFiles(targetProjectDirectory, this.options.supportFileExts!, this.options.ignoreFileNames);
}
三大任务详解
任务 1: 记录项目根目录 (this.targetProjectDirectory = targetProjectDirectory)
发生了什么:
这行代码非常直接,它把你传入的字符串 'tests' 保存到了 SceneConfig 对象的 targetProjectDirectory 这个内部属性里。为什么重要:
这是整个分析的 “锚点”。后续所有操作,比如解析 tsconfig.json、查找依赖、构建 Scene 等,都需要知道项目的根目录在哪里。targetProjectDirectory 就是这个基准路径。
任务 2: 推断项目名称 (this.targetProjectName = path.basename(targetProjectDirectory))
path.basename('tests') 会返回路径的最后一部分,也就是 'tests' 这个字符串本身。所以,targetProjectName 属性也被赋值为 'tests'。如果你的路径是 'C:/Users/MyUser/MyApp',那么项目名就会被推断为 'MyApp' 项目名称在 Scene 中用于标识和区分不同的代码来源,尤其是在进行跨项目分析或处理依赖时,这个名称会非常有用。
任务 3: 扫描并收集所有源文件 (this.projectFiles = getAllFiles(...)
它调用了一个名为 getAllFiles 的辅助函数(源码在 src/utils/getAllFiles.ts)。这个函数会:
从你指定的根目录 'tests' 开始。
递归地 遍历 'tests' 文件夹以及其下的所有子文件夹。
在遍历过程中,它会检查每一个文件的后缀名。
如果一个文件的后缀名存在于 this.options.supportFileExts 数组中(默认是 ['.ets', '.ts']),那么这个文件的 完整绝对路径 就会被收集起来。
如果配置了 ignoreFileNames,它还会跳过这些被忽略的文件。
最终,getAllFiles 返回一个包含所有符合条件的源文件绝对路径的字符串数组。
这个数组被赋值给 SceneConfig 对象的 projectFiles 属性。 projectFiles 列表就是 Scene 对象接下来需要处理的 “工作清单”。在 scene.buildSceneFromProjectDir(config) 这一步中,Scene 会从 config 对象中获取这个文件列表,然后对列表中的每一个文件路径,执行我们之前讨论过的“读取 -> 解析AST -> 构建ArkFile”的完整流程。没有这个文件列表,Scene 就不知道要分析哪些文件。
所以,config.buildFromProjectDir('tests'); 这句看似简单的代码,实际上完成了一个至关重要的预处理阶段。它为 SceneConfig 对象填充了三个核心属性:
targetProjectDirectory: 'tests' (分析的根在哪)
targetProjectName: 'tests' (分析的是什么项目)
projectFiles: ['D:\codeArk\tests\main.ts', 'D:\codeArk\tests\models\user.ts', 'D:\codeArk\tests\services\authService.ts'] (具体要分析哪些文件,路径是绝对的)
当这个 config 对象被传递给 new Scene() 并用于构建 Scene 时,Scene 就拥有了开始正式分析所需的所有初始信息。
4.buildSceneFromProjectDir源码
public buildSceneFromProjectDir(sceneConfig: SceneConfig): void {
//环境初始化
this.buildBasicInfo(sceneConfig);
//生成ArkFile
this.genArkFiles();
}
关键调用路径:buildSceneFromProjectDir->genArkFile->buildMethodBody->buildBody->build
关键调用路径逐步分析:https://blog.csdn.net/2302_80118884/article/details/151649408?spm=1001.2014.3001.5502
4.IR
它的标准格式是:
result = operand1 operator operand2
这行代码里正好有三个“地址”(或者说,三个变量/值的位置):
result: 存放结果的地址。
operand1: 第一个操作数的地址。
operand2: 第二个操作数的地址。
这就是它叫“三地址码”的原因。
代码解读 (Stmt.ts, Expr.ts)
Stmt (Statement, 语句):可以理解为一条完整的 “指令”。
Expr (Expression, 表达式):可以理解为构成指令的 “操作数”或“计算过程”。
Stmt.ts (语句): 这里定义了所有可能的三地址码 指令。比如:
ArkAssignStmt: 赋值语句 (x = y)
ArkInvokeStmt: 调用语句 (foo(a, b))
ArkIfStmt: 条件跳转 (if (x > 0) goto L1)
ArkReturnStmt: 返回 (return x)
Expr.ts (表达式): 这里定义了构成语句的各种 操作数和计算。比如:
ArkInstanceInvokeExpr: 封装了调用一个实例方法所需的所有信息。ArkNewExpr: new MyClass() ArkConditionExpr: a < b
例子:let result = myCalculator.add(5, b);
[ArkAssignStmt]
/ \
/ \
(leftOp) / \ (rightOp)
/ \
[Local (name='result')] [ArkInstanceInvokeExpr]
/ | \
/ | \
(base) / (methodSignature) \ (args)
/ | \
[Local (name='myCalculator')] [MethodSignature] [Array]
(points to 'add' method) |
|
-------------------
| |
(element 0) | | (element 1)
| |
[Constant (value=5)] [Local (name='b')]
CFG → 调用图(CG)
这是“建立全局联系”阶段。 我们检查每个方法的 CFG,找出里面的调用指令,从而绘制出整个项目中方法与方法之间的调用关系网。
代码解读 (CallGraphBuilder.ts)
buildDirectCallGraph 方法:
public buildDirectCallGraph(methods: ArkMethod[]): void {
this.buildCGNodes(methods);
for (const method of methods) {
let cfg = method.getCfg();
if (cfg === undefined) {
// abstract method cfg is undefined
continue;
}
let stmts = cfg.getStmts();
//遍历一个方法CFG中每一条IR语句
for (const stmt of stmts) {
//检查这条语句是不是一个调用语句,
// 是的话就提取出被调用方法的签名(方法名+参数类型)
let invokeExpr = stmt.getInvokeExpr();
if (invokeExpr === undefined) {
continue;
}
//区分静态调用和动态调用。
// 如果是静态调用(比如 Math.random()),目标函数 callee 是唯一确定的。
// 如果是动态调用(比如 new Array()),目标函数 callee 需要通过类型推断来确定。
let callee: Method | undefined = this.getDCCallee(invokeExpr);
// abstract method will also be added into direct cg
if (callee && invokeExpr instanceof ArkStaticInvokeExpr) {
//如果是静态调用,直接在调用图(cg)中添加一条从当前方法到目标方法的边。
this.cg.addDirectOrSpecialCallEdge(method.getSignature(), callee, stmt);
} else {
//如果是动态调用(比如 obj.run(),obj 的具体类型不确定),就暂时记录下这个调用点的信息,
// 等待后续的 CHA/RTA 分析来解析它可能的目标。
this.cg.addDynamicCallInfo(stmt, method.getSignature(), callee);
}
}
}
}
buildClassHierarchyCallGraph / buildRapidTypeCallGraph:
这两个方法就是用来处理上面留下的动态调用问题的。它们利用类继承关系 (CHA) 或更精确的类型推断结果 (RTA) 来推测动态调用可能链接到哪些具体的方法实现,从而把调用图补充完整。
第六步:CG → 数据流分析(IFDS 框架)
这是“深度分析与求解”阶段。 有了调用图,我们就可以追踪数据在方法之间的流动了。这里提供的是一个通用的 数据流分析框架。
代码解读 (DataflowProblem.ts, DataflowSolver.ts):
DataflowProblem.ts:
这是一个 abstract class (抽象类),定义了一个数据流问题的 “问卷”。
Java类比: 这就像一个 interface。如果你想实现一个特定的分析(比如“空指针分析”),你就需要继承这个类,并回答问卷上的所有问题:
getNormalFlowFunction: 普通语句(如赋值)如何改变数据流信息?
getCallFlowFunction: 当调用一个函数时,数据流信息如何从调用者传递给被调用者?
createZeroValue: 分析开始时,初始的数据流信息是什么?
这是一个非常优雅的设计, ArkAnalyzer的作者写好了通用的求解引擎(DataflowSolver),而用户只需要填写这份“问卷”就能定义自己的分析任务。
DataflowSolver.ts:
solve(): 这是求解器的入口。
processCallNode(...): 这是处理跨函数数据流的核心逻辑。 当分析流程遇到一个函数调用时,它会:
用CHA等手段解析出所有可能被调用的目标方法。
对每个目标方法,执行用户在 DataflowProblem 里定义的 getCallFlowFunction,计算出传入被调用方法的数据流信息。
将新的信息推入被调用方法的入口,继续分析。
同时,它还处理了从被调用方法返回时数据流如何影响调用者后续代码的逻辑(exit-to-return 和 call-to-return)。