从一个Bug看Android文本编辑的设计缺陷
💡 本文精华
- 承接上文:第一篇解析了跨进程通信,本篇深入应用进程内部,剖析文本操作的设计缺陷
- 核心矛盾:InputFilter期望控制所有文本操作,但delete()绕过了过滤机制
- 关键发现 :
replaceText()先delete后insert,delete传入空字符串"",InputFilter认为合法放行 - 深层原因:Span范围在insert失败后与实际文本不同步,导致范围异常扩展,吞噬更多字符
- 价值:理解Android文本编辑框架的设计局限,为第三篇的工程解决方案做准备
一、应用进程内部的文本编辑架构
1.1 三大核心组件
上一篇我们理解了Binder如何将输入法的调用传递到应用进程。现在聚焦应用进程内部:
scss
应用进程内部架构:
┌─────────────────────────────────────────┐
│ TextView / EditText │
│ ├─ Editable (SpannableStringBuilder)│ ← 文本缓冲区
│ │ - char[] mText │
│ │ - Object[] mSpans │
│ │ │
│ ├─ InputFilter[] │ ← 过滤器链
│ │ - DigitsKeyListener │
│ │ - LengthFilter │
│ │ │
│ └─ InputConnection │ ← 外部接口
│ - BaseInputConnection │
└─────────────────────────────────────────┘
上一篇已讲 :InputConnection是跨进程接口 本篇聚焦:Editable、InputFilter、Span如何协同工作
1.2 Editable接口设计
java
public interface Editable extends CharSequence, Spannable {
// 核心的三个操作
Editable insert(int where, CharSequence text);
Editable delete(int st, int en);
Editable replace(int st, int en, CharSequence text);
// 过滤器管理
void setFilters(InputFilter[] filters);
InputFilter[] getFilters();
}
实现类:SpannableStringBuilder
java
public class SpannableStringBuilder implements Editable {
private char[] mText; // 存储字符
private int mGapStart; // Gap Buffer优化
private int mGapLength;
private Object[] mSpans; // 存储Span对象
private int[] mSpanStarts; // Span起始位置
private int[] mSpanEnds; // Span结束位置
private int[] mSpanFlags; // Span标志
// 核心实现
@Override
public SpannableStringBuilder replace(int start, int end,
CharSequence tb, int tbstart, int tbend) {
// 关键:这里会调用InputFilter链
InputFilter[] filters = getFilters();
if (filters != null) {
for (int i = 0; i < filters.length; i++) {
CharSequence repl = filters[i].filter(tb, tbstart, tbend,
this, start, end);
if (repl != null) {
tb = repl;
tbstart = 0;
tbend = repl.length();
}
}
}
// 执行实际的文本替换
// ...
}
}
关键点:
- insert/delete最终都调用replace()
- replace()会触发InputFilter链
- 但有个例外...
二、设计缺陷的核心:不对称的控制权
2.1 InputFilter的接口设计
java
public interface InputFilter {
/**
* @param source 要插入的新文本
* @param start source的起始位置
* @param end source的结束位置
* @param dest 目标文本(当前EditText内容)
* @param dstart 插入位置的起始
* @param dend 插入位置的结束
* @return null = 接受source
* "" = 拒绝source
* 其他 = 替换为返回的内容
*/
CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend);
}
DigitsKeyListener的实现:
java
// NumberKeyListener.java (父类)
public CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend) {
char[] accept = getAcceptedChars(); // ['0'-'9']
for (int i = start; i < end; i++) {
if (!ok(accept, source.charAt(i))) {
// 发现非法字符 (如字母 'q')
if (end - start == 1) {
return ""; // 单个字符,直接拒绝
}
// 多个字符,过滤掉非法的
// ...
}
}
return null; // 全部合法,接受
}
private boolean ok(char[] accept, char c) {
for (int i = accept.length - 1; i >= 0; i--) {
if (accept[i] == c) {
return true;
}
}
return false;
}
2.2 delete()的特殊实现
java
// SpannableStringBuilder.java
public SpannableStringBuilder delete(int start, int end) {
// 关键:delete本质是replace操作,传入空字符串""
return replace(start, end, "");
}
// 调用链
delete(5, 6)
↓
replace(5, 6, "", 0, 0)
↓
filter("", 0, 0, "123456", 5, 6) ← InputFilter看到的是空字符串
↓
// DigitsKeyListener的检查
for (int i = 0; i < 0; i++) { // start=0, end=0,循环不执行
// 检查字符合法性
}
↓
return null; // 接受!
问题本质:
scss
┌─────────────────────────────────────────────────┐
│ InputFilter的视角 │
├─────────────────────────────────────────────────┤
│ │
│ insert(5, "abc"): │
│ filter("abc", 0, 3, dest, 5, 5) │
│ → 检查 'a', 'b', 'c' → 拒绝 ✓ │
│ │
│ delete(5, 8): │
│ filter("", 0, 0, dest, 5, 8) │
│ → 检查空字符串 → 接受 ✗ │
│ │
│ InputFilter无法区分: │
│ - 插入空字符串 (应接受) │
│ - 删除操作 (应该也能拦截吗?) │
│ │
└─────────────────────────────────────────────────┘
2.3 设计假设 vs 实际情况
| 场景 | 设计假设 | 实际情况 | 是否合理 |
|---|---|---|---|
| 用户按删除键 | delete应该无条件执行 | delete绕过InputFilter | ✓ 合理(尊重用户意图) |
| InputFilter拦截insert | 拦截非法字符插入 | 只能拦截insert | ✓ 符合预期 |
| 组合文本替换 | 替换应该原子执行 | delete先于insert,分离执行 | ✗ 不合理 |
| delete+insert组合 | 应该保持一致性 | delete绕过,insert拦截 | ✗ 不对称 |
三、BaseInputConnection的replaceText()实现
3.1 核心代码分析
java
// BaseInputConnection.java
private void replaceText(CharSequence text, int newCursorPosition, boolean composing) {
final Editable content = getEditable();
if (content == null) {
return;
}
beginBatchEdit();
// ========== 步骤1: 获取旧的组合文本范围 ==========
int a = getComposingSpanStart(content);
int b = getComposingSpanEnd(content);
if (b < a) {
int tmp = a;
a = b;
b = tmp;
}
if (a != -1 && b != -1) {
removeComposingSpans(content);
} else {
// 如果没有组合范围,使用选中范围
a = Selection.getSelectionStart(content);
b = Selection.getSelectionEnd(content);
}
// ========== 步骤2: 先删除旧范围 ==========
if (b != a) {
content.delete(a, b); // ← 关键: 先删除,不经过InputFilter验证!
}
// ========== 步骤3: 再插入新文本 ==========
if (text != null) {
content.insert(a, text); // ← 经过InputFilter,可能被拒绝
}
// ========== 步骤4: 设置新的组合范围 ==========
if (composing) {
setComposingSpans(content, a, a + text.length());
}
endBatchEdit();
}
3.2 Bug触发的完整流程
java
// 前提:EditText内容 "123456",光标在末尾
// ========== 第1次:setComposingText("q") ==========
replaceText("q", 1, true)
↓
步骤1: 获取组合范围
a = getComposingSpanStart() → -1 (无旧范围)
b = getComposingSpanEnd() → -1
使用光标位置: a=6, b=6
↓
步骤2: 删除
if (6 != 6) → false,不执行delete
↓
步骤3: 插入
content.insert(6, "q")
→ filter("q", 0, 1, "123456", 6, 6)
→ 检查: 'q' 不是数字
→ 返回 "" (拒绝)
→ 插入失败
↓
步骤4: 设置组合范围
setComposingSpans(6, 6 + 0) → [6, 6)
↓
结果: "123456" (未变)
// ========== 第2次:setComposingText("qq") ==========
replaceText("qq", 1, true)
↓
步骤1: 获取组合范围
a = getComposingSpanStart() → 5 ← 为什么是5? (稍后分析)
b = getComposingSpanEnd() → 6
↓
步骤2: 删除 ← Bug开始!
if (6 != 5) → true
content.delete(5, 6)
→ replace(5, 6, "", 0, 0)
→ filter("", 0, 0, "123456", 5, 6)
→ 循环不执行 (start=0, end=0)
→ 返回 null (接受)
→ 删除成功!字符'6'被删除
content = "12345"
↓
步骤3: 插入
content.insert(5, "qq")
→ filter("qq", 0, 2, "12345", 5, 5)
→ 检查: 'q' 不是数字
→ 返回 "" (拒绝)
→ 插入失败
↓
步骤4: 设置组合范围
setComposingSpans(5, 5 + 0) → [5, 5)
↓
结果: "12345" ✗ (丢失了'6')
3.3 为什么先delete后insert?
设计意图:
java
// 组合文本的替换逻辑
// 例如:拼音输入 "zhongguo" → 候选词 "中国"
// 旧组合文本: "zhongguo" (8个字符)
// 新组合文本: "中国" (2个字符)
// 如果先insert后delete:
insert("中国") → "123456zhongguo中国"
delete(6, 14) → "123456中国"
// 如果先delete后insert:
delete(6, 14) → "123456"
insert("中国") → "123456中国"
// 两种方式结果相同,但先delete后insert更高效
// (避免临时创建过长的字符串)
问题:
- 假设了insert总是成功
- 没有考虑InputFilter拒绝的情况
- 没有回滚机制
四、Span机制导致的范围异常扩展
4.1 COMPOSING Span的作用
java
// BaseInputConnection.java
private static final Object COMPOSING = new ComposingText();
private static class ComposingText implements NoCopySpan {
// 空实现,只作为标记
}
// 获取组合文本范围
static int getComposingSpanStart(Spannable text) {
return text.getSpanStart(COMPOSING);
}
static int getComposingSpanEnd(Spannable text) {
return text.getSpanEnd(COMPOSING);
}
// 设置组合文本范围
void setComposingSpans(Spannable text, int start, int end) {
// 移除旧的COMPOSING Span
removeComposingSpans(text);
// 设置新的COMPOSING Span
text.setSpan(COMPOSING, start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_COMPOSING);
}
4.2 Span范围的动态变化
java
// 初始状态
SpannableStringBuilder text = new SpannableStringBuilder("123456");
// 无COMPOSING Span
// 第1次 setComposingText("q")
setComposingSpans(6, 6) // 设置空范围Span
// text: "123456"
// Span: [6, 6) ← 空范围,但Span存在
// 第2次 setComposingText("qq")
// 1. 获取范围
int a = getComposingSpanStart() → 返回 5 ← 为什么?
int b = getComposingSpanEnd() → 返回 6
// 2. delete(5, 6)
text.delete(5, 6)
// text: "12345"
// Span被自动调整...
// 3. insert("qq") 失败
// 4. setComposingSpans(5, 5)
// text: "12345"
// Span: [5, 5)
4.3 为什么Span范围会从[6,6)变成[5,6)?
关键代码:setComposingSpans的实现
java
private void setComposingSpans(Spannable text, int start, int end) {
// 获取[start, end)范围内的所有Span
final Object[] sps = text.getSpans(start, end, Object.class);
if (sps != null) {
for (int i = sps.length - 1; i >= 0; i--) {
final Object o = sps[i];
if (o == COMPOSING) {
text.removeSpan(o);
continue;
}
final int fl = text.getSpanFlags(o);
// 关键:修改其他Span的范围和标志
if ((fl & (Spanned.SPAN_COMPOSING | Spanned.SPAN_POINT_MARK_MASK))
!= (Spanned.SPAN_COMPOSING | SPAN_EXCLUSIVE_EXCLUSIVE)) {
text.setSpan(o, start, end,
(fl & ~SPAN_POINT_MARK_MASK)
| SPAN_COMPOSING | SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}
// 设置新的COMPOSING Span
text.setSpan(COMPOSING, start, end,
SPAN_EXCLUSIVE_EXCLUSIVE | SPAN_COMPOSING);
}
问题根源:
scss
第1次 setComposingSpans(6, 6):
- 设置 COMPOSING Span [6, 6)
- text长度: 6
- 期望: Span在文本末尾
第2次 insert("q") 失败:
- text长度仍是6
- 但 setComposingSpans(6, 6 + "q".length()) 期望长度是7
- Span范围计算出现偏差
第3次 getComposingSpanStart():
- SpannableStringBuilder内部的Span位置计算
- 由于text长度与预期不符
- 返回了错误的起始位置 (5而不是6)
连锁反应:
[6, 6) → delete不执行 → [5, 6) → delete(5,6) → [4, 5) → delete(4,5) → ...
范围逐渐向前扩展,吞噬更多字符!
4.4 Span范围扩展的可视化
sql
文本: "123456"
索引: 012345
第1次操作后:
text: "123456"
Span: [6, 6) ← 空范围,在'6'之后
第2次操作:
1. 获取范围: [5, 6) ← 异常!为什么变了?
2. delete(5, 6): "12345" ← 删除'6'
3. insert失败
4. 设置Span: [5, 5) ← 在'5'之后
第3次操作:
1. 获取范围: [3, 5) ← 继续扩展
2. delete(3, 5): "123" ← 删除'4'和'5'
3. insert失败
4. 设置Span: [3, 3)
第4次操作:
1. 获取范围: [0, 3)
2. delete(0, 3): "" ← 删除全部
底层原因 :SpannableStringBuilder的Span位置计算依赖文本长度,当实际长度与预期不符时,Span的start/end会被错误地调整。
五、设计缺陷的层次分析
5.1 接口层:filter()无法区分操作类型
java
// 当前接口
CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend);
缺陷:
- 只看到
source(要插入的内容) - 看不到操作类型(insert? delete? replace?)
- 看不到操作来源(用户? 输入法? 系统?)
- 无法区分"插入空字符串"和"删除操作"
理想接口:
java
enum OperationType { INSERT, DELETE, REPLACE }
enum OperationSource { USER_KEY, IME, SYSTEM }
CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend,
OperationType type, // 操作类型
OperationSource source); // 操作来源
5.2 实现层:replaceText()缺乏原子性
java
// 当前实现(有缺陷)
private void replaceText(CharSequence text, ...) {
if (b != a) {
content.delete(a, b); // 可能删除合法内容
}
if (text != null) {
content.insert(a, text); // 可能被过滤
}
// 没有回滚机制!
}
改进方向1:先验证
java
private void replaceText(CharSequence text, ...) {
// 先检查新文本是否合法
if (text != null) {
CharSequence filtered = applyFilters(text, a, a);
if (filtered.length() == 0 && text.length() > 0) {
// 新文本被完全过滤,放弃替换
return;
}
text = filtered; // 使用过滤后的内容
}
// 再执行替换
if (b != a) {
content.delete(a, b);
}
if (text != null) {
content.insert(a, text);
}
}
改进方向2:使用原子replace
java
// 不要分开delete和insert,使用Editable.replace()
content.replace(a, b, text);
// replace()内部一次性调用filter
// filter可以同时看到删除范围[a,b)和插入内容text
5.3 协议层:Span范围不可靠
当前机制:
scss
setComposingSpans(start, end)
→ 假设文本长度会变为start + text.length()
→ 实际: insert失败,长度不变
→ Span范围与实际不符
→ 下次getComposingSpanStart/End()返回错误值
改进方向:引入版本号
java
class ComposingState {
int start;
int end;
int textVersion; // 文本版本号
// 每次文本操作增加版本号
void increment() {
textVersion++;
}
// 验证Span范围是否仍有效
boolean isValid(int currentVersion) {
return textVersion == currentVersion;
}
}
六、对比:正常场景 vs Bug场景
6.1 正常场景:用户主动删除
java
// 用户按删除键
onKeyDown(KeyEvent.KEYCODE_DEL)
↓
BaseInputConnection.deleteSurroundingText(1, 0)
↓
Editable.delete(cursorPos - 1, cursorPos)
↓
replace(5, 6, "", 0, 0)
↓
filter("", 0, 0, "123456", 5, 6)
↓
返回 null (接受)
↓
删除成功 ✓
这种情况下,delete应该被允许(用户主动操作)。
6.2 Bug场景:输入法自动替换
java
// 输入法调用 setComposingText("qq", 1)
BaseInputConnection.setComposingText("qq", 1)
↓
replaceText("qq", 1, true)
↓
delete(5, 6) + insert("qq")
↓
delete成功 + insert失败
↓
数据丢失 ✗
这种情况下,delete是"替换的一部分",应该与insert保持一致。
6.3 核心差异
| 维度 | 用户删除 | 输入法替换 |
|---|---|---|
| 操作意图 | 删除内容 | 替换内容(delete+insert) |
| 原子性 | 单个操作 | 组合操作 |
| 是否应拦截 | 否(尊重用户) | 是(保持一致性) |
| 实际行为 | delete通过 ✓ | delete通过,insert被拒 ✗ |
七、总结:设计的不对称性
7.1 核心矛盾
sql
┌─────────────────────────────────────────────────┐
│ 文本编辑框架的不对称性 │
├─────────────────────────────────────────────────┤
│ │
│ InputFilter 的期望: │
│ 控制所有文本操作 │
│ │
│ Editable 的实现: │
│ insert() → 经过filter ✓ │
│ delete() → 绕过filter ✗ │
│ │
│ replaceText() 的假设: │
│ delete() 总是成功 │
│ insert() 也总是成功 │
│ │
│ 实际场景: │
│ delete() 成功 │
│ insert() 被InputFilter拒绝 │
│ → 数据丢失 │
│ │
│ Span 的副作用: │
│ 范围计算依赖文本长度 │
│ insert失败导致长度不符 │
│ → 范围异常扩展 │
│ │
└─────────────────────────────────────────────────┘
7.2 循序渐进的知识链
第一篇:Binder通信机制
- ✓ 理解了输入法→应用的跨进程调用
- ✓ 理解了异步执行导致的信息不对称
- → setComposingText("qq")被传递到应用进程
第二篇(本文):文本编辑设计缺陷
- ✓ 理解了replaceText()的先delete后insert
- ✓ 理解了delete绕过InputFilter的机制
- ✓ 理解了Span范围异常扩展的原因
- → 理解了为什么数字会被删除
第三篇预告:工程解决方案
- 如何在InputConnection层拦截非法组合文本?
- 完整的解决方案(保留数字键盘)
- 三种方案对比与最佳实践
- → 从理论到实践
参考资料
- Android源码:
frameworks/base/core/java/android/text/SpannableStringBuilder.java - Android源码:
frameworks/base/core/java/android/view/inputmethod/BaseInputConnection.java - Android源码:
frameworks/base/core/java/android/text/method/DigitsKeyListener.java - 官方文档:InputFilter
下一篇:《InputConnection机制与跨进程文本操作的工程实践》- 完整的解决方案,既保留数字键盘,又避免数据丢失