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输入法框架,并在实际项目中游刃有余地解决类似问题。

相关推荐
wdfk_prog1 小时前
[Linux]学习笔记系列 -- [kernel]cpu
linux·笔记·学习
WAsbry1 小时前
Android输入法框架的Binder通信机制剖析
android
WAsbry1 小时前
从一个Bug看Android文本编辑的设计缺陷
android·linux
沐怡旸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