从一个Bug看Android文本编辑的设计缺陷

从一个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层拦截非法组合文本?
  • 完整的解决方案(保留数字键盘)
  • 三种方案对比与最佳实践
  • → 从理论到实践

参考资料

  1. Android源码:frameworks/base/core/java/android/text/SpannableStringBuilder.java
  2. Android源码:frameworks/base/core/java/android/view/inputmethod/BaseInputConnection.java
  3. Android源码:frameworks/base/core/java/android/text/method/DigitsKeyListener.java
  4. 官方文档:InputFilter

下一篇:《InputConnection机制与跨进程文本操作的工程实践》- 完整的解决方案,既保留数字键盘,又避免数据丢失

相关推荐
WAsbry1 小时前
Android输入法框架的Binder通信机制剖析
android
沐怡旸2 小时前
【底层机制】Android低内存管理机制深度解析
android
大聪明-PLUS2 小时前
Linux 中 timeout、watch 和 at 的指南:管理命令执行时间
linux·嵌入式·arm·smarc
wuwu_q3 小时前
用通俗易懂 + Android 开发实战的方式讲解 Kotlin Flow 中的 filter 操作符
android·开发语言·kotlin
想唱rap3 小时前
Linux开发工具(4)
linux·运维·服务器·开发语言·算法
robin59113 小时前
Linux-通过端口转发访问数据库
linux·数据库·adb
视觉AI4 小时前
如何查看 Linux 下正在运行的 Python 程序是哪一个
linux·人工智能·python
扣脚大汉在网络4 小时前
如何在centos 中运行arm64程序
linux·运维·centos
lang201509284 小时前
Linux命令行:cat、more、less终极指南
linux·chrome·less