Skip to content

Commit eeee792

Browse files
authored
Merge pull request #768 from kingzcheung/main
refactor(ime): 重构IME键盘回调中的insets计算机制
2 parents af738db + e416252 commit eeee792

11 files changed

Lines changed: 936 additions & 122 deletions

File tree

app/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,9 @@ dependencies {
256256
testImplementation(libs.kotlinx.coroutines.core)
257257
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
258258
testImplementation("org.mockito:mockito-core:5.23.0")
259-
testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
259+
testImplementation("org.mockito.kotlin:mockito-kotlin:6.3.0")
260+
// JVM 单测使用真实 org.json 实现(android.jar 内为抛异常的 stub)
261+
testImplementation("org.json:json:20240303")
260262

261263
androidTestImplementation(libs.androidx.junit)
262264
androidTestImplementation(libs.androidx.espresso.core)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.kingzcheung.xime.handwriting.capture
2+
3+
/**
4+
* 单个手写采集样本:一个目标字 + 原始笔迹。
5+
*
6+
* 坐标语义与键盘手写(HandwritingKeyboardLayout)一致:
7+
* - x/y 为作画区局部像素坐标(左上角原点,y 向下),未做任何归一化;
8+
* - 时间为相对本次样本首笔起点的毫秒数(首笔起点 = 0)。
9+
* 归一化由训练侧复刻推理侧 HandwritingEngine.strokesToSequence 的
10+
* bounding-box 算法完成,采集端只存原材料。
11+
*/
12+
data class HandwritingSample(
13+
val target: String,
14+
val canvasWidthPx: Float,
15+
val canvasHeightPx: Float,
16+
val strokes: List<List<StrokePointMs>>,
17+
/** 采集时的识别首选(用于离线对比模型效果),无模型/未识别时为 null */
18+
val modelTop: String? = null,
19+
/** 识别首选得分,无识别时为 null */
20+
val modelTopScore: Float? = null,
21+
)
22+
23+
/** 带相对时间的采样点(ms 相对本样本首笔起点)。 */
24+
data class StrokePointMs(
25+
val x: Float,
26+
val y: Float,
27+
val t: Long,
28+
)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.kingzcheung.xime.handwriting.capture
2+
3+
import org.json.JSONArray
4+
import org.json.JSONObject
5+
import java.io.ByteArrayOutputStream
6+
import java.nio.charset.Charset
7+
8+
/**
9+
* HandwritingSample <-> JSON 编解码。
10+
*
11+
* JSON 结构(训练侧契约):
12+
* ```
13+
* {
14+
* "format": "xime-handwriting-capture",
15+
* "version": 1,
16+
* "normalization": "per-stroke-set bounding box: (x-minX)/rangeX, (y-minY)/rangeY",
17+
* "meta": { "app_version", "device", "android", "exported_at_ms", "sample_count" },
18+
* "samples": [
19+
* {
20+
* "target": "中",
21+
* "canvas": { "w": 520.0, "h": 380.0 },
22+
* "strokes": [ { "t0": 0, "pts": [[x,y,dt], ...] }, { "t0": 430, ... } ],
23+
* "model_top": "中" | 缺省,
24+
* "model_top_score": 0.97 | 缺省
25+
* }
26+
* ]
27+
* }
28+
* ```
29+
* - x/y 为作画区局部像素坐标(原点左上,y 向下),未归一化;
30+
* - t0 为笔画起点相对样本首笔起点的毫秒数,pts 内第三列为相对 t0 的毫秒数;
31+
* - 归一化在训练侧执行,算法与推理侧 HandwritingEngine.strokesToSequence 一致。
32+
*/
33+
object HandwritingSampleCodec {
34+
35+
const val FORMAT = "xime-handwriting-capture"
36+
const val VERSION = 1
37+
38+
fun samplesToJsonArray(samples: List<HandwritingSample>): JSONArray {
39+
val arr = JSONArray()
40+
for (sample in samples) arr.put(sampleToJson(sample))
41+
return arr
42+
}
43+
44+
fun sampleToJson(sample: HandwritingSample): JSONObject {
45+
val obj = JSONObject()
46+
obj.put("target", sample.target)
47+
obj.put(
48+
"canvas",
49+
JSONObject().put("w", sample.canvasWidthPx.toDouble()).put("h", sample.canvasHeightPx.toDouble())
50+
)
51+
val strokes = JSONArray()
52+
for (stroke in sample.strokes) {
53+
val s = JSONObject()
54+
val t0 = stroke.firstOrNull()?.t ?: 0L
55+
s.put("t0", t0)
56+
val pts = JSONArray()
57+
for (p in stroke) {
58+
pts.put(JSONArray().put(p.x.toDouble()).put(p.y.toDouble()).put(p.t - t0))
59+
}
60+
s.put("pts", pts)
61+
strokes.put(s)
62+
}
63+
obj.put("strokes", strokes)
64+
sample.modelTop?.let { obj.put("model_top", it) }
65+
sample.modelTopScore?.let { obj.put("model_top_score", it.toDouble()) }
66+
return obj
67+
}
68+
69+
fun buildExportJson(
70+
samples: List<HandwritingSample>,
71+
appVersion: String,
72+
device: String,
73+
androidVersion: String,
74+
exportedAtMs: Long,
75+
): String {
76+
val root = JSONObject()
77+
root.put("format", FORMAT)
78+
root.put("version", VERSION)
79+
root.put(
80+
"normalization",
81+
"per-sample bounding box: (x-minX)/max(rangeX,1), (y-minY)/max(rangeY,1); " +
82+
"identical to HandwritingEngine.strokesToSequence"
83+
)
84+
root.put(
85+
"meta",
86+
JSONObject()
87+
.put("app_version", appVersion)
88+
.put("device", device)
89+
.put("android", androidVersion)
90+
.put("exported_at_ms", exportedAtMs)
91+
.put("sample_count", samples.size)
92+
)
93+
root.put("samples", samplesToJsonArray(samples))
94+
return root.toString(2)
95+
}
96+
97+
fun parseFromJson(text: String): List<HandwritingSample> {
98+
val root = JSONObject(text)
99+
if (root.optString("format") != FORMAT) return emptyList()
100+
val arr = root.optJSONArray("samples") ?: return emptyList()
101+
val out = mutableListOf<HandwritingSample>()
102+
for (i in 0 until arr.length()) {
103+
val s = arr.optJSONObject(i) ?: continue
104+
val target = s.optString("target")
105+
if (target.isEmpty()) continue
106+
val canvas = s.optJSONObject("canvas")
107+
val w = canvas?.optDouble("w")?.toFloat() ?: 0f
108+
val h = canvas?.optDouble("h")?.toFloat() ?: 0f
109+
val modelTop = s.optString("model_top").ifEmpty { null }
110+
val modelTopScore = if (s.has("model_top_score")) s.optDouble("model_top_score").toFloat() else null
111+
val strokes = mutableListOf<List<StrokePointMs>>()
112+
val sArr = s.optJSONArray("strokes") ?: JSONArray()
113+
for (j in 0 until sArr.length()) {
114+
val st = sArr.optJSONObject(j) ?: continue
115+
val t0 = st.optLong("t0")
116+
val ptsArr = st.optJSONArray("pts") ?: JSONArray()
117+
val pts = mutableListOf<StrokePointMs>()
118+
for (k in 0 until ptsArr.length()) {
119+
val p = ptsArr.optJSONArray(k) ?: continue
120+
if (p.length() < 3) continue
121+
pts.add(
122+
StrokePointMs(
123+
x = p.optDouble(0).toFloat(),
124+
y = p.optDouble(1).toFloat(),
125+
t = t0 + p.optLong(2),
126+
)
127+
)
128+
}
129+
if (pts.isNotEmpty()) strokes.add(pts)
130+
}
131+
if (strokes.isNotEmpty()) {
132+
out.add(HandwritingSample(target, w, h, strokes, modelTop, modelTopScore))
133+
}
134+
}
135+
return out
136+
}
137+
}

app/src/main/java/com/kingzcheung/xime/service/ImeKeyboardCallbacks.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,8 @@ internal fun rememberImeKeyboardCallbacks(
365365
quickSendEditingItemText = "",
366366
enterKeyText = "确定",
367367
)
368-
// 不在此处 forceInsetsRecompute:此刻 Compose 尚未布局,hack 会用旧高度
369-
// 白跑一轮且 pending=true 挡掉撑高后的第一次 y 更新(曾导致表单可见
370-
// 却不可触摸)。布局撑高完成后 onGloballyPositioned 会以新 y 触发重算。
368+
// 撑高由 SideEffect 驱动:uiState 变化 → Compose 内容高度变化 →
369+
// updateHeight 改容器物理高度 → relayout → onComputeInsets 自动重算。
371370
},
372371
onQuickSendEditItem = { id, text ->
373372
// 同 onShowQuickSendForm:编辑入口在剪贴板面板内,先退出 Overlay 防表单被盖
@@ -395,8 +394,8 @@ internal fun rememberImeKeyboardCallbacks(
395394
)
396395
QuickSendFormEditTextHolder.editText = null
397396
service.keyboardViewModel.showOverlay(OverlayRoute.Clipboard(1))
398-
// 兜底:强制容器真实尺寸变化,触发 IME insets 重算恢复为纯键盘高度(防残留)。
399-
service.forceInsetsRecompute()
397+
// insets 恢复由 SideEffect 驱动:表单收起 → 容器物理高度还原 →
398+
// relayout → onComputeInsets 自动重算,无需强制触发。
400399
},
401400
onQuickSendFormFocusChange = { focused: Boolean ->
402401
// 聚焦时回车键为"确定";失焦不改文案——表单仍显示,回车保持"确定"

app/src/main/java/com/kingzcheung/xime/service/VoiceKeyboardContainer.kt

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,6 @@ class VoiceKeyboardContainer(
4343
}
4444
}
4545

46-
fun resetHeight() {
47-
val params = layoutParams
48-
if (params != null && params.height != FrameLayout.LayoutParams.MATCH_PARENT) {
49-
params.height = FrameLayout.LayoutParams.MATCH_PARENT
50-
layoutParams = params
51-
requestLayout()
52-
}
53-
}
54-
5546
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
5647
ev?.let {
5748
when (it.action) {

0 commit comments

Comments
 (0)