Skip to content

Commit df718a2

Browse files
committed
feat: support BuilderParam slot patches
1 parent 9b46cf2 commit df718a2

23 files changed

Lines changed: 3660 additions & 188 deletions

File tree

‎README.md‎

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ ohpm install @rickytan/ohospatch
168168
```json5
169169
{
170170
"dependencies": {
171-
"@rickytan/ohospatch": "^1.0.0"
171+
"@rickytan/ohospatch": "^1.0.3"
172172
}
173173
}
174174
```
@@ -219,21 +219,32 @@ Patch 脚本可以引用声明文件获得 IDE 补全:
219219
完整目标路径格式是:
220220

221221
```text
222-
bundleName/moduleName/[packageName/]src/main/ets/File#ExportName
222+
bundleName/moduleName/[packagePath/]src/main/ets/File#ExportName
223+
@package/name/src/main/ets/File#ExportName
224+
/src/main/ets/File#ExportName
223225
```
224226

225-
Runtime 解析路径时以 `src/main/ets` 为锚点:前两段固定是 `bundleName/moduleName`,锚点前剩余的所有段都会作为 OH package name。因此 scoped package 可以直接写完整包名:
227+
Runtime 解析路径时以 `src/main/ets` 为锚点。完整路径里前两段是 `bundleName/moduleName`,锚点前剩余的所有段都会作为 OH package path:
226228

227229
```text
228230
com.example.app/entry/@google/somelib/src/main/ets/foo/Bar#Bar
229231
```
230232

231233
上面会解析为 `moduleInfo = com.example.app/entry`,`modulePath = @google/somelib/src/main/ets/foo/Bar`。
232234

233-
例如 Demo 的 `PatchablePanel` 导出自 `entry/src/main/ets/demo/PatchablePanel.ets`,所以目标为:
235+
`#ExportName` 必须对应 ArkTS 文件中实际 `export` 出来的类、函数或自定义 Component。OhosPatch 通过 `napi_load_module_with_info` 加载模块后只能从模块导出对象上取值;没有 `export` 的内部类型、文件内局部类、未导出的 `@Entry` 页面或只在文件内部使用的 helper,运行时无法定位,也就不能作为 `Fixit.fix()`、`Fixit.component()` 或 `Fixit.import()` 的目标。需要被 patch 的业务类或 Component 应保持稳定导出名,并在开启混淆时为这些导出和需要 hook 的方法配置 keep 规则。
236+
237+
如果目标在当前运行的 APP/module 中,可以省略 `bundleName/moduleName`。`@` 开头表示当前 host module 下的 OH package path;`/` 开头表示当前 host module 自己的源码路径。host 信息来自 `OhosPatch.init(context)`:
238+
239+
```text
240+
@vendor/business_page/src/main/ets/components/PatchablePanel#PatchablePanel
241+
/src/main/ets/pages/Index#Index
242+
```
243+
244+
例如 Demo 的 `PatchablePanel` 来自 `@vendor/business_page` 包,因此推荐写法为:
234245

235246
```text
236-
com.rickytan.ohospatch/entry/src/main/ets/demo/PatchablePanel#PatchablePanel
247+
@vendor/business_page/src/main/ets/components/PatchablePanel#PatchablePanel
237248
```
238249

239250
下面每个示例都先列出已经发布在 APP 中、无需为 OhosPatch 修改的原始 ArkTS 代码,再列出下发的 JavaScript Patch。
@@ -572,6 +583,64 @@ var originSubmit = panel.node({ // 返回 NodeBuilder。
572583

573584
自定义组件是边界。父组件中的 `<Child />` 只是父组件渲染路径上的一个自定义组件创建点,不会把 `Child` 内部的 `Text/Button` 计入父组件 selector;要修复子组件内部节点,需要对 `Child` 自己再写一条 `Fixit.component(childFullPath)`。
574585

586+
这个边界有一个明确的例外:父组件传给子组件的尾随闭包或 `@BuilderParam` 内容仍归父组件所有,可以通过 `.node(childSelector).slot(nodeSelector)` 修复。原始组件例如:
587+
588+
```ts
589+
@Component
590+
export struct ContentShell {
591+
@BuilderParam contentBuilder: () => void
592+
593+
build() {
594+
Column() {
595+
Text('Shell title') // 子组件自己创建,不属于 slot
596+
this.contentBuilder()
597+
}
598+
}
599+
}
600+
601+
@Component
602+
export struct ParentPanel {
603+
@State statusText: string = 'Original'
604+
605+
build() {
606+
ContentShell() {
607+
Text(`status=${this.statusText}`) // slot Text occurrence 0
608+
Button('Action') // slot Button occurrence 0
609+
.height(44)
610+
.onClick(() => {
611+
this.statusText = 'Original click'
612+
})
613+
}
614+
}
615+
}
616+
```
617+
618+
对应 Patch:
619+
620+
```js
621+
var parent = Fixit.component(
622+
'@vendor/business/src/main/ets/ParentPanel#ParentPanel'
623+
); // 返回 ComponentFix,目标是拥有尾随闭包的父组件。
624+
625+
var child = parent.node({
626+
type: '@vendor/business/src/main/ets/ContentShell#ContentShell', // 选择父组件创建的自定义子组件。
627+
occurrence: 0 // 选择第一个 ContentShell 实例。
628+
}); // 返回 ComponentNodeFix。
629+
630+
child.slot({ type: 'Text', occurrence: 0 }) // 在该实例收到的 BuilderParam 内容中选择第一个 Text。
631+
.attrs({ fontColor: '#C44736', fontSize: 18 }); // 返回 ComponentSlotNodeFix。
632+
633+
var originClick = child.slot({ type: 'Button', occurrence: 0 }) // slot 内各节点类型独立从 0 计数。
634+
.attrs({ height: 52, backgroundColor: '#C44736' }) // 覆盖原 Button 属性;返回 ComponentSlotNodeFix。
635+
.event('onClick', function () { // 返回 OriginEvent;this 是 ParentPanel 当前实例。
636+
var result = originClick.apply(this, arguments); // 调用并取得原 onClick 返回值。
637+
this.statusText = 'Patched click'; // 原回调执行后修改父组件状态。
638+
return result; // 保持原事件返回语义。
639+
});
640+
```
641+
642+
`.slot()` 只覆盖由父组件提供的 builder 内容,不会选择 `ContentShell` 自己创建的 `Text('Shell title')`。slot 内的 `occurrence` 每次执行该 BuilderParam 时独立计数,因此上例的 slot Text 是 `0`,不受父组件其他 Text 或子组件标题 Text 影响。slot 节点同样支持 `type + where`;未找到节点时保持 no-op 并输出一次 warning。
643+
575644
条件渲染时,只统计当前状态下实际执行到的分支。`if` 分支里第一个 `Button` 是该分支执行时的 `Button occurrence: 0`;切到 `else` 分支后,`else` 分支里实际创建的同类型节点会重新按执行顺序计数。循环和 `ForEach` 也是同一规则:每个实际执行的迭代都会按顺序贡献节点,因此列表长度、排序或过滤条件变化会改变后续同类型节点的 `occurrence`。
576645

577646
更具体地说,编译后的组件渲染会在执行到每个 ArkUI 内置节点 builder 时累加计数;没有执行到的分支不会占位。
@@ -978,13 +1047,27 @@ $HOME/Library/OpenHarmony/Sdk
9781047

9791048
路径不同时,通过环境或仓库变量 `DEVECO_STUDIO_HOME`、`OHOS_BASE_SDK_HOME` 覆盖。
9801049

1050+
## 构建产物
1051+
1052+
`ohospatch` 的内置 Patch Runtime 源码位于 `ohospatch/src/main/cpp/runtime/fixit.js`。构建 HAR/HAP 时会通过 Gulp 调用 Terser,把它压缩为 `ohospatch/src/main/resources/rawfile/ohospatch/fixit.min.js` 后打进 rawfile。
1053+
1054+
当前压缩不是简单删除注释和空白,而是使用 Terser 的 `compress` 和 `mangle` 能力:
1055+
1056+
```bash
1057+
npm install --prefix tools/fixit-runtime-build
1058+
npm run build:fixit-runtime -- --input ohospatch/src/main/cpp/runtime/fixit.js --output ohospatch/src/main/resources/rawfile/ohospatch/fixit.min.js
1059+
```
1060+
1061+
CMake 构建也会调用同一个 Gulp task;如果本地没有安装 Node 依赖,会明确提示先在项目根目录执行 `npm install --prefix tools/fixit-runtime-build`。
1062+
9811063
## 当前边界
9821064

9831065
- prototype hook 不覆盖构造函数、实例字段箭头函数、私有实现或不经过属性查找的调用点。
1066+
- 只有从 ArkTS 模块 `export` 出来的类、函数和自定义 Component 能被 patch;未导出的内部类型无法通过运行时模块导出表定位。
9841067
- Patch handler 的 `this` Proxy 只在当前同步调用或 `origin` 调用期间有效,不应保存到 timer、Promise 或全局变量后异步访问。
9851068
- `Fixit.import()` 返回的持久 Proxy 可保留到 `OhosPatch.clear()` 或下一次 patch 替换。
9861069
- 普通方法参数和新建 JS 对象仍受 JSON wire 类型限制。
987-
- Component DSL 当前支持 API 20 状态管理 V1/V2、导出的自定义组件、首选的 `type + occurrence` 节点选择器、`type + where` 原始属性选择器、JSON 属性参数和同步事件替换。
1070+
- Component DSL 当前支持 API 20 状态管理 V1/V2、导出的自定义组件、父组件尾随闭包/`@BuilderParam` slot、首选的 `type + occurrence` 节点选择器、`type + where` 原始属性选择器、JSON 属性参数和同步事件替换。
9881071
- 非导出的 `@Entry` 页面、层级选择器、已挂载组件主动刷新,以及 `before/after/around` 事件组合尚未支持。`Resource`、Controller 等不可 JSON 序列化对象不能作为 selector 或静态 attr 参数直接下发。
9891072
- 单个 runtime 最多同时存在 256 个 timer。
9901073
- 单个 patch 最多保留 512 个去重后的动态导入类、实例、方法或嵌套对象句柄。

‎business_page/build-profile.json5‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"apiType": "stageMode",
33
"buildOption": {
4+
"arkOptions": {
5+
"autoLazyImport": true
6+
},
47
"resOptions": {
58
"copyCodeResource": {
69
"enable": false
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
@Component
2+
export struct BuilderParamShell {
3+
@Prop title: string = 'BuilderParam shell';
4+
@BuilderParam contentBuilder: () => void;
5+
6+
build() {
7+
Column({ space: 10 }) {
8+
Text(this.title)
9+
.fontSize(16)
10+
.fontWeight(FontWeight.Medium)
11+
.fontColor('#27313D')
12+
.width('100%')
13+
14+
this.contentBuilder()
15+
}
16+
.width('100%')
17+
.padding(12)
18+
.backgroundColor('#FFFFFF')
19+
.borderRadius(8)
20+
}
21+
}
22+
23+
@Component
24+
export struct BuilderParamRoot {
25+
@Prop message: string = 'Original builder-param root parameter';
26+
@State statusText: string = 'Original builder-param state';
27+
28+
@Builder
29+
private SlotContent() {
30+
Text(`slot text=${this.statusText}`)
31+
.fontSize(14)
32+
.fontColor('#526070')
33+
.width('100%')
34+
.id('builder-slot-text')
35+
36+
Button('Builder slot action')
37+
.width('100%')
38+
.height(44)
39+
.backgroundColor('#59636E')
40+
.id('builder-slot-button')
41+
.onClick(() => {
42+
this.statusText = 'Original builder slot clicked';
43+
})
44+
}
45+
46+
build() {
47+
Column({ space: 10 }) {
48+
Text(this.message)
49+
.fontSize(18)
50+
.fontWeight(FontWeight.Bold)
51+
.fontColor('#142033')
52+
.width('100%')
53+
.id('builder-root-message')
54+
55+
BuilderParamShell({ title: 'Custom BuilderParam shell' }) {
56+
this.SlotContent()
57+
}
58+
59+
BuilderParamShell({ title: 'Inline BuilderParam shell' }) {
60+
Text(`inline slot text=${this.statusText}`)
61+
.fontSize(14)
62+
.fontColor('#526070')
63+
.width('100%')
64+
.id('builder-inline-slot-text')
65+
66+
Button('Inline builder slot action')
67+
.width('100%')
68+
.height(44)
69+
.backgroundColor('#59636E')
70+
.id('builder-inline-slot-button')
71+
.onClick(() => {
72+
this.statusText = 'Original inline builder slot clicked';
73+
})
74+
}
75+
76+
Text(`root status=${this.statusText}`)
77+
.fontSize(13)
78+
.fontColor('#526070')
79+
.width('100%')
80+
.id('builder-root-status')
81+
}
82+
.width('100%')
83+
.padding(14)
84+
.backgroundColor('#FFFFFF')
85+
.borderRadius(8)
86+
}
87+
}

‎business_page/src/main/ets/components/DemoPatch.ets‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DemoResult, DemoScenario } from "../ViewModel/DemoScenario";
2+
import { BuilderParamRoot } from "./BuilderParamPanel";
23
import { ParentChildParamPanel } from "./ChildParamPanel";
34
import { PatchablePanel } from "./PatchablePanel";
45
import { PatchablePanelV2 } from "./PatchablePanelV2";
@@ -32,6 +33,10 @@ struct DemoPatchScreen {
3233

3334
ParentChildParamPanel()
3435

36+
BuilderParamRoot({
37+
message: 'Original builder-param root parameter'
38+
})
39+
3540
Button('Run method hook scenario')
3641
.width('100%')
3742
.height(44)
@@ -68,4 +73,4 @@ struct DemoPatchScreen {
6873

6974
export {
7075
DemoPatchScreen
71-
}
76+
}

‎entry/build-profile.json5‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
{
22
"apiType": "stageMode",
33
"buildOption": {
4+
"arkOptions": {
5+
"autoLazyImport": true,
6+
"runtimeOnly": {
7+
"packages": [
8+
"@vendor/business_page"
9+
]
10+
}
11+
},
412
"resOptions": {
513
"copyCodeResource": {
614
"enable": false

‎entry/src/main/module.json5‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"deliveryWithInstall": true,
1212
"installationFree": false,
1313
"pages": "$profile:main_pages",
14+
"appStartup": "$profile:demo_patch_startup",
1415
"requestPermissions": [
1516
{
1617
"name": "ohos.permission.INTERNET"

‎entry/src/main/resources/rawfile/patch.js‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
(function (Fixit) {
44
// --- Import: 跨模块加载 ArkTS 类(返回持久 Proxy,可 new / 静态 / 实例调用)---
55
var Point = Fixit.import(
6-
'com.rickytan.ohospatch/entry/@vendor/business_page/src/main/ets/ViewModel/Point#Point'
6+
'@vendor/business_page/src/main/ets/ViewModel/Point#Point'
77
);
88

99
// --- Patch 目标 ---
1010
var fix = Fixit.fix(
11-
'com.rickytan.ohospatch/entry/@vendor/business_page/src/main/ets/ViewModel/DemoViewModel#DemoViewModel'
11+
'@vendor/business_page/src/main/ets/ViewModel/DemoViewModel#DemoViewModel'
1212
);
1313
var panel = Fixit.component(
14-
'com.rickytan.ohospatch/entry/@vendor/business_page/src/main/ets/components/PatchablePanel#PatchablePanel'
14+
'@vendor/business_page/src/main/ets/components/PatchablePanel#PatchablePanel'
1515
);
1616
var panelV2 = Fixit.component(
17-
'com.rickytan.ohospatch/entry/@vendor/business_page/src/main/ets/components/PatchablePanelV2#PatchablePanelV2'
17+
'@vendor/business_page/src/main/ets/components/PatchablePanelV2#PatchablePanelV2'
18+
);
19+
var builderParamRoot = Fixit.component(
20+
'@vendor/business_page/src/main/ets/components/BuilderParamPanel#BuilderParamRoot'
1821
);
1922

2023
// --- Timer + console 日志 ---
@@ -157,16 +160,67 @@
157160
});
158161

159162
Fixit.component(
160-
'com.rickytan.ohospatch/entry/@vendor/business_page/src/main/ets/components/ChildParamPanel#ParentChildParamPanel'
163+
'@vendor/business_page/src/main/ets/components/ChildParamPanel#ParentChildParamPanel'
161164
)
162165
.node({
163-
type: 'com.rickytan.ohospatch/entry/@vendor/business_page/src/main/ets/components/ChildParamPanel#ChildParamPanel',
166+
type: '@vendor/business_page/src/main/ets/components/ChildParamPanel#ChildParamPanel',
164167
occurrence: 1
165168
})
166169
.param('title', function (originValue) {
167170
return 'scoped patched ' + originValue;
168171
});
169172

173+
// --- BuilderParam reproduction: root param/state should work; slot nodes expose whether
174+
// current node DSL can see ArkUI nodes built inside a child Component's trailing builder.
175+
builderParamRoot.param('message', 'Patched builder-param root parameter');
176+
builderParamRoot.state('statusText', 'Patched builder-param root state');
177+
builderParamRoot.node({
178+
type: '@vendor/business_page/src/main/ets/components/BuilderParamPanel#BuilderParamShell',
179+
occurrence: 0
180+
})
181+
.slot({ type: 'Text', occurrence: 0 })
182+
.attrs({
183+
fontColor: '#C44736',
184+
fontSize: 18
185+
});
186+
var originBuilderSlotClick = builderParamRoot.node({
187+
type: '@vendor/business_page/src/main/ets/components/BuilderParamPanel#BuilderParamShell',
188+
occurrence: 0
189+
})
190+
.slot({ type: 'Button', occurrence: 0 })
191+
.attrs({
192+
backgroundColor: '#C44736',
193+
height: 52
194+
})
195+
.event('onClick', /** @this {any} */ function () {
196+
var result = originBuilderSlotClick.apply(this, arguments);
197+
this.statusText = 'Patched builder slot click';
198+
return result;
199+
});
200+
builderParamRoot.node({
201+
type: '@vendor/business_page/src/main/ets/components/BuilderParamPanel#BuilderParamShell',
202+
occurrence: 1
203+
})
204+
.slot({ type: 'Text', occurrence: 0 })
205+
.attrs({
206+
fontColor: '#1F6B46',
207+
fontSize: 18
208+
});
209+
var originInlineBuilderSlotClick = builderParamRoot.node({
210+
type: '@vendor/business_page/src/main/ets/components/BuilderParamPanel#BuilderParamShell',
211+
occurrence: 1
212+
})
213+
.slot({ type: 'Button', occurrence: 0 })
214+
.attrs({
215+
backgroundColor: '#1F6B46',
216+
height: 52
217+
})
218+
.event('onClick', /** @this {any} */ function () {
219+
var result = originInlineBuilderSlotClick.apply(this, arguments);
220+
this.statusText = 'Patched inline builder slot click';
221+
return result;
222+
});
223+
170224
// --- Method patch: 实例方法 ---
171225
// locationOf:越界时改写 this 上的嵌套属性并返回默认值;命中时委托 origin。
172226
var originLocation = fix.instanceMethod('locationOf', function (locations, index, point) {

0 commit comments

Comments
 (0)