InputConnection机制与跨进程文本操作的工程实践

InputConnection机制与跨进程文本操作的工程实践

💡 本文精华

  • 承接前文:第一篇讲Binder通信,第二篇讲设计缺陷,本篇给出完整的工程解决方案
  • 核心需求:既保留数字键盘(inputType="number"),又避免字母输入导致数字丢失
  • 最佳方案 :自定义InputConnection在setComposingText()层拦截非法字符,阻止进入delete-insert流程
  • 三种方案对比:InputConnection拦截(推荐⭐⭐⭐⭐⭐)、TextWatcher过滤(⭐⭐⭐)、自定义InputFilter(⭐⭐⭐⭐)
  • 价值:提供可直接使用的完整代码,分析方案优劣,指导工程实践

一、需求分析与约束条件

1.1 核心需求

markdown 复制代码
用户需求:
1. 点击EditText时,弹出数字键盘(不是全键盘)
2. 只允许输入数字字符
3. 切换到字母键盘输入字母时,不应导致已有数字丢失

技术约束:
1. 必须使用 android:inputType="number" (保证数字键盘)
2. 不能修改Android框架代码
3. 需要兼容主流输入法(百度、搜狗、讯飞等)
4. 性能开销要小

1.2 问题回顾

前两篇已分析的根本原因

css 复制代码
输入法进程:
  ic.setComposingText("qq", 1)
  ↓ Binder IPC
应用进程:
  BaseInputConnection.replaceText("qq", 1, true)
  ↓
  1. delete(5, 6) → 成功(绕过InputFilter)
  2. insert("qq") → 失败(被DigitsKeyListener拒绝)
  ↓
  结果: 数据丢失

解决方向

  • 在delete执行之前拦截
  • 判断新文本是否合法
  • 如果非法,不进入delete-insert流程

二、解决方案一:InputConnection拦截(推荐⭐⭐⭐⭐⭐)

2.1 核心思路

css 复制代码
原始流程(有Bug):
setComposingText("qq")
  → replaceText()
  → delete(5, 6) ← 删除数字
  → insert("qq") ← 被拒绝
  → Bug!

优化后流程:
setComposingText("qq")
  → NumberInputConnectionWrapper.setComposingText()
  → 检测到字母 'q'
  → 调用 finishComposingText() ← 直接完成组合,不执行delete-insert
  → 数字保持不变 ✓

2.2 完整实现代码

java 复制代码
public class RecommendedNumberEditText extends AppCompatEditText {

    public RecommendedNumberEditText(Context context) {
        super(context);
    }

    public RecommendedNumberEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public RecommendedNumberEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        // 获取父类创建的InputConnection
        InputConnection base = super.onCreateInputConnection(outAttrs);
        if (base == null) {
            return null;
        }

        // 包装成我们的自定义InputConnection
        return new NumberInputConnectionWrapper(base, true);
    }

    /**
     * 自定义InputConnection包装类
     * 拦截setComposingText,检查是否包含非数字字符
     */
    private static class NumberInputConnectionWrapper extends InputConnectionWrapper {

        public NumberInputConnectionWrapper(InputConnection target, boolean mutable) {
            super(target, mutable);
        }

        @Override
        public boolean setComposingText(CharSequence text, int newCursorPosition) {
            // 关键:在执行delete-insert之前检查
            if (text != null && containsNonDigit(text)) {
                // 包含非数字字符,直接完成组合(不执行delete-insert)
                return super.finishComposingText();
            }

            // 纯数字或null,正常处理
            return super.setComposingText(text, newCursorPosition);
        }

        /**
         * 检查CharSequence是否包含非数字字符
         */
        private boolean containsNonDigit(CharSequence text) {
            for (int i = 0; i < text.length(); i++) {
                char c = text.charAt(i);
                if (!Character.isDigit(c)) {
                    return true; // 发现非数字字符
                }
            }
            return false; // 全部是数字
        }
    }
}

2.3 使用方法

xml 复制代码
<!-- activity_main.xml -->
<com.yourpackage.RecommendedNumberEditText
    android:id="@+id/edit_number"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="请输入数字"
    android:inputType="number" />
java 复制代码
// MainActivity.java
RecommendedNumberEditText editText = findViewById(R.id.edit_number);
// 正常使用,无需额外代码

2.4 方案优势

优势 说明
拦截时机早 在delete之前拦截,从源头解决问题
性能最优 不执行delete-insert,避免无效操作
对输入法透明 返回true,输入法认为成功,不影响正常流程
不影响正常输入 数字输入不受影响,仍走原有逻辑
代码简洁 只需重写一个方法
数字键盘保留 inputType="number"保证数字键盘弹出

2.5 工作流程图

css 复制代码
用户操作:
  输入数字 "123456"
  ↓
  切换到字母键盘
  ↓
  点击 'q'

输入法进程:
  ic.setComposingText("q", 1)
  ↓ Binder IPC

应用进程:
  NumberInputConnectionWrapper.setComposingText("q", 1)
  ↓
  containsNonDigit("q") → true (检测到字母)
  ↓
  调用 finishComposingText()
  ↓
  BaseInputConnection.finishComposingText()
    - 移除COMPOSING Span
    - 不执行delete
    - 不执行insert
  ↓
  返回 true
  ↓
  内容保持 "123456" ✓

继续点击 'q':
  同样流程,每次都调用 finishComposingText()
  内容始终保持 "123456" ✓

三、解决方案二:TextWatcher过滤(⭐⭐⭐)

3.1 核心思路

复制代码
原理:
不依赖InputFilter,而是监听文本变化
在文本改变后,过滤掉非数字字符

3.2 完整实现代码

java 复制代码
public class SimpleNumberEditText extends AppCompatEditText {

    private boolean isFiltering = false; // 防止递归

    public SimpleNumberEditText(Context context) {
        super(context);
        init();
    }

    public SimpleNumberEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public SimpleNumberEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        // 不设置inputType为number,避免DigitsKeyListener
        // 但会丢失数字键盘,需要手动设置
        setRawInputType(InputType.TYPE_CLASS_NUMBER);

        addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
                // 不需要处理
            }

            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                // 不需要处理
            }

            @Override
            public void afterTextChanged(Editable s) {
                if (isFiltering) {
                    return; // 避免递归
                }

                // 过滤掉非数字字符
                String filtered = s.toString().replaceAll("[^0-9]", "");

                if (!s.toString().equals(filtered)) {
                    isFiltering = true;

                    // 保存光标位置
                    int cursorPos = getSelectionStart();
                    int lengthBefore = s.length();

                    // 替换为过滤后的内容
                    setText(filtered);

                    // 调整光标位置
                    int lengthAfter = filtered.length();
                    int diff = lengthBefore - lengthAfter;
                    int newCursorPos = Math.max(0, cursorPos - diff);
                    setSelection(Math.min(newCursorPos, filtered.length()));

                    isFiltering = false;
                }
            }
        });
    }
}

3.3 方案优劣

维度 评价
优点 实现简单,易于理解
优点 不依赖InputConnection,逻辑独立
缺点 需要额外的setText操作,性能略差
缺点 光标位置处理复杂
缺点 会触发额外的TextWatcher回调

3.4 性能对比

css 复制代码
方案一(InputConnection拦截):
  setComposingText("qq")
    → 检测到字母
    → finishComposingText()
    → 1次操作

方案二(TextWatcher):
  setComposingText("qq")
    → delete(5, 6) → 删除'6'
    → insert("qq") → 被拒绝
    → afterTextChanged触发
    → setText("123456") → 恢复
    → 3次操作

性能差距:方案二约3倍于方案一

四、解决方案三:自定义InputFilter(⭐⭐⭐⭐)

4.1 核心思路

sql 复制代码
原理:
不仅拦截insert,也拦截delete
通过判断source是否为空来区分操作类型

4.2 完整实现代码

java 复制代码
public class SafeNumberEditText extends AppCompatEditText {

    public SafeNumberEditText(Context context) {
        super(context);
        init();
    }

    public SafeNumberEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public SafeNumberEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        // 设置自定义Filter,替换系统的DigitsKeyListener
        setFilters(new InputFilter[]{new SafeNumberInputFilter()});
    }

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        InputConnection base = super.onCreateInputConnection(outAttrs);
        if (base == null) {
            return null;
        }

        // 同时使用InputConnection拦截(双重保护)
        return new SafeInputConnectionWrapper(base, true);
    }

    /**
     * 自定义InputFilter
     * 拦截非数字字符的插入
     */
    private static class SafeNumberInputFilter implements InputFilter {
        @Override
        public CharSequence filter(CharSequence source, int start, int end,
                                   Spanned dest, int dstart, int dend) {
            // 过滤掉非数字字符
            StringBuilder filtered = new StringBuilder();
            for (int i = start; i < end; i++) {
                char c = source.charAt(i);
                if (Character.isDigit(c)) {
                    filtered.append(c);
                }
            }

            // 如果全部被过滤掉,返回空字符串
            if (filtered.length() == 0 && end > start) {
                return "";
            }

            // 如果有字符被过滤掉,返回过滤后的内容
            if (filtered.length() < (end - start)) {
                return filtered.toString();
            }

            // 全部合法,返回null(接受原始内容)
            return null;
        }
    }

    /**
     * InputConnection包装类
     * 作为第二层防护
     */
    private static class SafeInputConnectionWrapper extends InputConnectionWrapper {

        public SafeInputConnectionWrapper(InputConnection target, boolean mutable) {
            super(target, mutable);
        }

        @Override
        public boolean setComposingText(CharSequence text, int newCursorPosition) {
            if (text != null && containsNonDigit(text)) {
                return super.finishComposingText();
            }
            return super.setComposingText(text, newCursorPosition);
        }

        private boolean containsNonDigit(CharSequence text) {
            for (int i = 0; i < text.length(); i++) {
                if (!Character.isDigit(text.charAt(i))) {
                    return true;
                }
            }
            return false;
        }
    }
}

4.3 方案特点

特点 说明
双重保护 InputFilter + InputConnection两层拦截
灵活性高 可以自定义过滤规则(如允许小数点)
兼容性好 适配各种输入场景
代码复杂 需要同时实现Filter和Wrapper

五、三种方案对比与选型指南

5.1 全面对比

维度 方案一:InputConnection 方案二:TextWatcher 方案三:自定义Filter
实现复杂度 ⭐⭐ (简单) ⭐ (最简单) ⭐⭐⭐⭐ (复杂)
性能 ⭐⭐⭐⭐⭐ (最优) ⭐⭐⭐ (一般) ⭐⭐⭐⭐ (好)
拦截时机 早(delete之前) 晚(文本已改变) 早(但有局限)
数字键盘 ✓ 自动 ✓ 手动设置 ✓ 自动
光标处理 ✓ 自动 ✗ 需手动 ✓ 自动
代码量 ~40行 ~60行 ~80行
推荐度 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐

5.2 选型建议

makefile 复制代码
场景1: 普通数字输入框
  推荐: 方案一(InputConnection拦截)
  理由: 性能最优,代码最简洁

场景2: 需要自定义过滤规则(如电话号码:数字 + '-')
  推荐: 方案三(自定义Filter + InputConnection)
  理由: 灵活性高,可自定义allowed字符集

场景3: 快速原型开发
  推荐: 方案二(TextWatcher)
  理由: 最简单,快速实现

场景4: 高性能要求(如大量EditText)
  推荐: 方案一(InputConnection拦截)
  理由: 性能开销最小

场景5: 复杂输入验证(如金额:数字 + 小数点,最多2位小数)
  推荐: 方案三(自定义Filter)
  理由: 可以在filter()中实现复杂规则

5.3 实际项目中的使用

java 复制代码
// 示例:金额输入框(允许数字和小数点,最多2位小数)
public class MoneyEditText extends AppCompatEditText {

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        InputConnection base = super.onCreateInputConnection(outAttrs);
        if (base == null) {
            return null;
        }

        return new MoneyInputConnectionWrapper(base, true);
    }

    private static class MoneyInputConnectionWrapper extends InputConnectionWrapper {

        public MoneyInputConnectionWrapper(InputConnection target, boolean mutable) {
            super(target, mutable);
        }

        @Override
        public boolean setComposingText(CharSequence text, int newCursorPosition) {
            // 检查是否符合金额格式
            if (text != null && !isValidMoneyInput(text)) {
                return super.finishComposingText();
            }
            return super.setComposingText(text, newCursorPosition);
        }

        private boolean isValidMoneyInput(CharSequence text) {
            // 允许:数字、小数点
            String str = text.toString();
            // 简单验证:只包含[0-9.]
            return str.matches("[0-9.]*");
        }
    }

    // 可以配合InputFilter做更复杂的验证(如最多2位小数)
    private static class MoneyInputFilter implements InputFilter {
        @Override
        public CharSequence filter(CharSequence source, int start, int end,
                                   Spanned dest, int dstart, int dend) {
            // 组合后的完整文本
            String beforeInsert = dest.subSequence(0, dstart).toString();
            String afterInsert = dest.subSequence(dend, dest.length()).toString();
            String newText = beforeInsert + source.subSequence(start, end) + afterInsert;

            // 验证:最多一个小数点
            if (newText.indexOf('.') != newText.lastIndexOf('.')) {
                return ""; // 拒绝(多个小数点)
            }

            // 验证:小数点后最多2位
            int dotIndex = newText.indexOf('.');
            if (dotIndex != -1 && newText.length() - dotIndex > 3) {
                return ""; // 拒绝(超过2位小数)
            }

            return null; // 接受
        }
    }
}

六、工程实践的最佳实践

6.1 性能监控

java 复制代码
public class MonitoredNumberEditText extends AppCompatEditText {

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        InputConnection base = super.onCreateInputConnection(outAttrs);
        if (base == null) {
            return null;
        }

        return new MonitoredInputConnectionWrapper(base, true);
    }

    private static class MonitoredInputConnectionWrapper extends InputConnectionWrapper {

        public MonitoredInputConnectionWrapper(InputConnection target, boolean mutable) {
            super(target, mutable);
        }

        @Override
        public boolean setComposingText(CharSequence text, int newCursorPosition) {
            long startTime = System.nanoTime();

            boolean result;
            if (text != null && containsNonDigit(text)) {
                result = super.finishComposingText();
            } else {
                result = super.setComposingText(text, newCursorPosition);
            }

            long duration = System.nanoTime() - startTime;
            if (duration > 1_000_000) { // 超过1ms
                Log.w("InputMonitor", "setComposingText slow: " + duration / 1_000_000.0 + "ms");
            }

            return result;
        }

        private boolean containsNonDigit(CharSequence text) {
            for (int i = 0; i < text.length(); i++) {
                if (!Character.isDigit(text.charAt(i))) {
                    return true;
                }
            }
            return false;
        }
    }
}

6.2 日志调试

java 复制代码
public class DebugNumberEditText extends AppCompatEditText {

    private static final String TAG = "DebugNumberEditText";

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        InputConnection base = super.onCreateInputConnection(outAttrs);
        if (base == null) {
            return null;
        }

        return new DebugInputConnectionWrapper(base, true);
    }

    private static class DebugInputConnectionWrapper extends InputConnectionWrapper {

        public DebugInputConnectionWrapper(InputConnection target, boolean mutable) {
            super(target, mutable);
        }

        @Override
        public boolean setComposingText(CharSequence text, int newCursorPosition) {
            Log.d(TAG, "setComposingText: text='" + text + "', cursor=" + newCursorPosition);

            boolean hasNonDigit = false;
            if (text != null) {
                for (int i = 0; i < text.length(); i++) {
                    char c = text.charAt(i);
                    if (!Character.isDigit(c)) {
                        hasNonDigit = true;
                        Log.w(TAG, "  Detected non-digit: '" + c + "' at index " + i);
                    }
                }
            }

            boolean result;
            if (hasNonDigit) {
                Log.d(TAG, "  Action: finishComposingText() (rejecting non-digit input)");
                result = super.finishComposingText();
            } else {
                Log.d(TAG, "  Action: setComposingText() (accepting digit input)");
                result = super.setComposingText(text, newCursorPosition);
            }

            Log.d(TAG, "  Result: " + result);
            return result;
        }

        @Override
        public boolean commitText(CharSequence text, int newCursorPosition) {
            Log.d(TAG, "commitText: text='" + text + "', cursor=" + newCursorPosition);
            return super.commitText(text, newCursorPosition);
        }

        @Override
        public boolean finishComposingText() {
            Log.d(TAG, "finishComposingText");
            return super.finishComposingText();
        }
    }
}

6.3 单元测试

java 复制代码
@RunWith(RobolectricTestRunner.class)
public class NumberEditTextTest {

    @Test
    public void testDigitInput() {
        // 测试正常数字输入
        RecommendedNumberEditText editText = new RecommendedNumberEditText(ApplicationProvider.getApplicationContext());
        editText.setText("123");

        InputConnection ic = editText.onCreateInputConnection(new EditorInfo());
        ic.setComposingText("456", 1);

        assertEquals("123456", editText.getText().toString());
    }

    @Test
    public void testLetterInput() {
        // 测试字母输入(应该被拒绝)
        RecommendedNumberEditText editText = new RecommendedNumberEditText(ApplicationProvider.getApplicationContext());
        editText.setText("123");

        InputConnection ic = editText.onCreateInputConnection(new EditorInfo());
        ic.setComposingText("abc", 1);

        // 内容不应该变化
        assertEquals("123", editText.getText().toString());
    }

    @Test
    public void testMixedInput() {
        // 测试混合输入
        RecommendedNumberEditText editText = new RecommendedNumberEditText(ApplicationProvider.getApplicationContext());
        editText.setText("123");

        InputConnection ic = editText.onCreateInputConnection(new EditorInfo());
        ic.setComposingText("4a5", 1);

        // 包含字母,应该被拒绝
        assertEquals("123", editText.getText().toString());
    }
}

6.4 兼容性测试

java 复制代码
/**
 * 测试不同输入法的兼容性
 */
public class IMECompatibilityTest {

    @Test
    public void testBaiduIME() {
        // 模拟百度输入法的行为
        // ...
    }

    @Test
    public void testSogouIME() {
        // 模拟搜狗输入法的行为
        // ...
    }

    @Test
    public void testGboard() {
        // 模拟Google Gboard的行为
        // ...
    }
}

七、常见问题与解答

7.1 为什么不直接去掉inputType="number"?

问题:去掉inputType后,不会有Bug,为什么不这样做?

解答

ini 复制代码
去掉 inputType="number" 的后果:
1. 弹出全键盘(而不是数字键盘)
2. 用户体验变差(输入数字需要切换键盘)
3. 无法使用系统的数字键盘优化

保留 inputType="number" + InputConnection拦截:
1. 保留数字键盘(用户体验好)
2. 避免数据丢失(技术正确)
3. 最佳实践

7.2 为什么调用finishComposingText()?

问题:为什么不直接return false?

解答

java 复制代码
// 错误做法
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
    if (containsNonDigit(text)) {
        return false; // ✗ 输入法会认为失败,可能重试或报错
    }
    return super.setComposingText(text, newCursorPosition);
}

// 正确做法
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
    if (containsNonDigit(text)) {
        return super.finishComposingText(); // ✓ 告诉输入法"组合已完成"
    }
    return super.setComposingText(text, newCursorPosition);
}

原因

  • finishComposingText() 是合法的操作,输入法会正常处理
  • 返回 false 表示操作失败,输入法可能出现异常行为
  • finishComposingText() 会清除COMPOSING Span,避免后续问题

7.3 为什么不用setFilters完全替代?

问题:为什么不只用InputFilter,还要用InputConnection?

解答

markdown 复制代码
单独使用 InputFilter 的问题:
1. 无法阻止delete操作(delete绕过Filter)
2. 仍会触发Bug

单独使用 InputConnection 的优势:
1. 拦截时机早(delete之前)
2. 从源头解决问题

最佳实践:
  InputConnection拦截为主(方案一)
  或 InputFilter + InputConnection双重保护(方案三)

7.4 如何支持小数点?

扩展方案

java 复制代码
private boolean isValidNumberInput(CharSequence text) {
    for (int i = 0; i < text.length(); i++) {
        char c = text.charAt(i);
        // 允许数字和小数点
        if (!Character.isDigit(c) && c != '.') {
            return false;
        }
    }
    return true;
}

@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
    if (text != null && !isValidNumberInput(text)) {
        return super.finishComposingText();
    }
    return super.setComposingText(text, newCursorPosition);
}

// 同时设置inputType
setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);

八、总结:从理论到实践的完整链路

8.1 三篇文章的知识链

sql 复制代码
第一篇:Binder通信机制
  ├─ 三进程架构
  ├─ AIDL接口定义
  ├─ Stub/Proxy实现
  ├─ 线程切换
  └─ 信息不对称
       ↓ 为什么输入法不知道实际结果

第二篇:文本编辑设计缺陷
  ├─ InputFilter接口局限
  ├─ delete绕过Filter
  ├─ replaceText先delete后insert
  ├─ Span范围异常扩展
  └─ 不对称的控制权
       ↓ 为什么数字会被删除

第三篇(本文):工程解决方案
  ├─ InputConnection拦截(推荐)
  ├─ TextWatcher过滤(简单)
  ├─ 自定义InputFilter(灵活)
  ├─ 三种方案对比
  ├─ 选型指南
  ├─ 最佳实践
  └─ 完整代码
       ↓ 如何在实际项目中解决

8.2 最佳实践总结

场景 推荐方案 核心代码
普通数字输入 InputConnection拦截 finishComposingText()
自定义规则 自定义Filter + IC filter() + finishComposingText()
快速开发 TextWatcher afterTextChanged() + setText()
高性能 InputConnection拦截 避免多余操作

8.3 核心要点

markdown 复制代码
关键设计决策:
1. 保留 inputType="number" (数字键盘)
2. 重写 onCreateInputConnection() (拦截入口)
3. 包装 InputConnectionWrapper (扩展功能)
4. 拦截 setComposingText() (关键方法)
5. 调用 finishComposingText() (正确处理)

为什么这样设计:
1. 用户体验:数字键盘更方便
2. 性能优化:拦截早,开销小
3. 兼容性好:对输入法透明
4. 代码简洁:只需40行代码
5. 易于维护:逻辑清晰

8.4 延伸应用

java 复制代码
// 可以扩展到其他场景:

// 1. 手机号输入(数字 + 空格/横线)
public class PhoneNumberEditText extends AppCompatEditText {
    // ...
}

// 2. 邮箱输入(限制特殊字符)
public class EmailEditText extends AppCompatEditText {
    // ...
}

// 3. 用户名输入(字母 + 数字 + 下划线)
public class UsernameEditText extends AppCompatEditText {
    // ...
}

// 4. 密码输入(增强安全)
public class SecurePasswordEditText extends AppCompatEditText {
    // ...
}

九、完整示例项目结构

css 复制代码
app/src/main/java/com/yourpackage/
├─ widget/
│  ├─ RecommendedNumberEditText.java   ← 方案一(推荐)
│  ├─ SimpleNumberEditText.java        ← 方案二
│  ├─ SafeNumberEditText.java          ← 方案三
│  ├─ MoneyEditText.java               ← 扩展:金额输入
│  ├─ PhoneNumberEditText.java         ← 扩展:电话输入
│  └─ DebugNumberEditText.java         ← 调试版本
│
├─ utils/
│  └─ InputValidationUtils.java        ← 通用验证工具
│
└─ MainActivity.java

app/src/main/res/layout/
└─ activity_main.xml

app/src/test/java/com/yourpackage/
└─ NumberEditTextTest.java             ← 单元测试

参考资料

  1. Android源码:frameworks/base/core/java/android/view/inputmethod/InputConnectionWrapper.java
  2. 官方文档:InputConnectionWrapper
  3. 官方文档:InputConnection
  4. 本系列前两篇:
    • 第一篇:《Android输入法框架的Binder通信机制剖析》
    • 第二篇:《从一个Bug看Android文本编辑的设计缺陷》

附录:完整可运行的示例代码

RecommendedNumberEditText.java(直接复制可用):

java 复制代码
package com.yourpackage.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import androidx.appcompat.widget.AppCompatEditText;

/**
 * 推荐的数字输入框实现
 * 解决 inputType="number" 时,切换字母键盘导致数字丢失的问题
 *
 * 使用方法:
 * <com.yourpackage.widget.RecommendedNumberEditText
 *     android:inputType="number"
 *     ... />
 */
public class RecommendedNumberEditText extends AppCompatEditText {

    public RecommendedNumberEditText(Context context) {
        super(context);
    }

    public RecommendedNumberEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public RecommendedNumberEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        InputConnection base = super.onCreateInputConnection(outAttrs);
        if (base == null) {
            return null;
        }
        return new NumberInputConnectionWrapper(base, true);
    }

    /**
     * 自定义InputConnection包装类
     * 拦截setComposingText,检查是否包含非数字字符
     */
    private static class NumberInputConnectionWrapper extends InputConnectionWrapper {

        public NumberInputConnectionWrapper(InputConnection target, boolean mutable) {
            super(target, mutable);
        }

        @Override
        public boolean setComposingText(CharSequence text, int newCursorPosition) {
            if (text != null && containsNonDigit(text)) {
                // 包含非数字字符,直接完成组合(不执行delete-insert)
                return super.finishComposingText();
            }
            // 纯数字或null,正常处理
            return super.setComposingText(text, newCursorPosition);
        }

        /**
         * 检查是否包含非数字字符
         */
        private boolean containsNonDigit(CharSequence text) {
            for (int i = 0; i < text.length(); i++) {
                if (!Character.isDigit(text.charAt(i))) {
                    return true;
                }
            }
            return false;
        }
    }
}

activity_main.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="推荐方案(InputConnection拦截):"
        android:textSize="16sp"
        android:textStyle="bold" />

    <com.yourpackage.widget.RecommendedNumberEditText
        android:id="@+id/edit_recommended"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="请输入数字"
        android:inputType="number" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:text="测试说明:"
        android:textSize="14sp" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="1. 输入数字 123456\n2. 切换到字母键盘\n3. 连续点击字母 'q'\n4. 数字不会被删除 ✓"
        android:textSize="12sp" />

</LinearLayout>

系列完结。从Binder通信机制,到文本编辑设计缺陷,再到完整的工程解决方案,希望这三篇文章能帮助你深入理解Android输入法框架,并在实际项目中游刃有余地解决类似问题。

相关推荐
chlk1231 天前
Linux文件权限完全图解:读懂 ls -l 和 chmod 755 背后的秘密
linux·操作系统
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
舒一笑1 天前
Ubuntu系统安装CodeX出现问题
linux·后端
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
改一下配置文件1 天前
Ubuntu24.04安装NVIDIA驱动完整指南(含Secure Boot解决方案)
linux
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
深紫色的三北六号1 天前
Linux 服务器磁盘扩容与目录迁移:rsync + bind mount 实现服务无感迁移(无需修改配置)
linux·扩容·服务迁移
SudosuBash2 天前
[CS:APP 3e] 关于对 第 12 章 读/写者的一点思考和题解 (作业 12.19,12.20,12.21)
linux·并发·操作系统(os)
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读