快速开始 - 问题概述
问题描述
在Flutter Android应用中,当使用iPad蓝牙键盘进行文本输入时,输入法的联想词UI(候选词窗口)无法正确跟随光标位置显示,而是固定在屏幕的某个位置,严重影响用户的输入体验。
问题的具体表现
- 使用iPad蓝牙键盘输入文本时
- 输入法联想词窗口位置固定,不跟随光标移动
- 联想词UI可能遮挡输入内容或显示在错误位置
- 影响用户选择联想词和查看输入内容
解决方案概览
通过修改Flutter Engine中的三个关键文件,实现输入法联想词UI正确跟随光标位置:
- InputConnectionAdaptor.java - 输入连接适配器(添加光标位置同步)
- TextInputChannel.java - 文本输入通道(增强光标位置传递)
- TextInputPlugin.java - 文本输入插件(实现光标矩形计算和传递)
涉及文件路径
ruby
~/Documents/writer/flutter_sdk/engine/src/flutter/shell/platform/android/
├── io/flutter/plugin/editing/
│ ├── InputConnectionAdaptor.java
│ └── TextInputPlugin.java
└── io/flutter/embedding/engine/systemchannels/
└── TextInputChannel.java
详细说明目录
- [3.1 问题根因分析](#3.1 问题根因分析 "#31-%E9%97%AE%E9%A2%98%E6%A0%B9%E5%9B%A0%E5%88%86%E6%9E%90")
- [3.2 TextInputPlugin.java 核心修改](#3.2 TextInputPlugin.java 核心修改 "#32-textinputpluginjava-%E6%A0%B8%E5%BF%83%E4%BF%AE%E6%94%B9")
- [3.3 TextInputChannel.java 光标位置传递](#3.3 TextInputChannel.java 光标位置传递 "#33-textinputchanneljava-%E5%85%89%E6%A0%87%E4%BD%8D%E7%BD%AE%E4%BC%A0%E9%80%92")
- [3.4 InputConnectionAdaptor.java 位置同步](#3.4 InputConnectionAdaptor.java 位置同步 "#34-inputconnectionadaptorjava-%E4%BD%8D%E7%BD%AE%E5%90%8C%E6%AD%A5")
- [3.5 核心技术实现原理](#3.5 核心技术实现原理 "#35-%E6%A0%B8%E5%BF%83%E6%8A%80%E6%9C%AF%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86")
- [3.6 编译验证流程](#3.6 编译验证流程 "#36-%E7%BC%96%E8%AF%91%E9%AA%8C%E8%AF%81%E6%B5%81%E7%A8%8B")
- [3.7 测试验证方法](#3.7 测试验证方法 "#37-%E6%B5%8B%E8%AF%95%E9%AA%8C%E8%AF%81%E6%96%B9%E6%B3%95")
- [3.8 常见问题排查](#3.8 常见问题排查 "#38-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5")
3.1 问题根因分析
iPad蓝牙键盘联想词UI问题的技术原因
问题表现:
- 使用iPad蓝牙键盘输入时,联想词窗口位置固定
- 联想词UI不跟随光标位置移动
- 可能遮挡正在输入的文本内容
- 影响用户查看和选择联想词
根本原因:
java
// 核心问题:Flutter Engine没有正确向Android输入法框架传递光标的屏幕坐标
// 导致输入法无法正确定位联想词UI的显示位置
// 问题根源:
// 1. TextInputPlugin.java - 缺少光标矩形(CursorRect)的计算和设置
// 2. TextInputChannel.java - 没有正确传递光标位置信息
// 3. InputConnectionAdaptor.java - 缺少向输入法框架的位置同步
Android输入法框架的光标位置机制
正常的输入法UI定位流程:
scss
┌─────────────────────────────────────────┐
│ Flutter Dart层 │
│ TextField 计算光标屏幕坐标 │
└─────────────────┬───────────────────────┘
│ Platform Channel
┌─────────────────▼───────────────────────┐
│ TextInputPlugin.java │
│ 接收光标位置,计算屏幕坐标 │
└─────────────────┬───────────────────────┘
│ setCursorRect()
┌─────────────────▼───────────────────────┐
│ InputConnectionAdaptor.java │
│ 调用输入法框架API设置光标位置 │
└─────────────────┬───────────────────────┘
│ requestCursorUpdates()
┌─────────────────▼───────────────────────┐
│ Android输入法框架 │
│ 根据光标位置显示联想词UI │
└─────────────────────────────────────────┘
3.2 TextInputPlugin.java 核心修改
文件位置
bash
~/Documents/writer/flutter_sdk/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java
关键修改点1:新增光标矩形存储变量
新增的成员变量:
java
// 在类成员变量区域新增(第55-56行)
@Nullable private Rect lastCursorRect; // 存储从Flutter传递的真实光标位置
@Nullable private double[] lastTransformMatrix; // 存储最新的变换矩阵
变量作用说明:
lastCursorRect
: 保存Flutter框架传递的光标矩形位置,经过坐标变换转换为Android屏幕坐标lastTransformMatrix
: 保存最新的坐标变换矩阵,用于将Flutter局部坐标转换为Android屏幕坐标
关键修改点2:在TextInputMethodHandler中新增setCursorRect处理
新增的接口实现:
java
// 在TextInputMethodHandler匿名实现中新增(第148-152行)
@Override
public void setCursorRect(double left, double top, double width, double height) {
saveCursorRect(left, top, width, height);
}
实现说明:
- 实现了
TextInputMethodHandler
接口中的setCursorRect
方法 - 接收来自Flutter框架的光标位置参数(left、top、width、height)
- 调用
saveCursorRect
方法进行坐标变换和存储
关键修改点3:增强createInputConnection方法
在InputConnection创建时添加光标位置设置:
java
// 在各个分支的处理逻辑:
if (inputTarget.type == InputTarget.Type.NO_TARGET) {
lastInputConnection = null;
return null;
}
if (inputTarget.type == InputTarget.Type.PHYSICAL_DISPLAY_PLATFORM_VIEW) {
return null;
}
if (inputTarget.type == InputTarget.Type.VIRTUAL_DISPLAY_PLATFORM_VIEW) {
if (isInputConnectionLocked) {
return lastInputConnection;
}
// ... 平台视图处理逻辑
}
// 在新建InputConnectionAdaptor后立即设置光标位置(第385-395行)
lastInputConnection = connection;
// 如果有已保存的光标位置,立即设置并触发更新
if (lastCursorRect != null) {
connection.setCursorRect(lastCursorRect);
connection.requestCursorUpdates(InputConnection.CURSOR_UPDATE_IMMEDIATE);
}
增强功能说明:
- 在创建新的InputConnectionAdaptor后立即检查是否有缓存的光标位置
- 如果有缓存的光标位置,立即调用
setCursorRect
和requestCursorUpdates
- 确保新建的输入连接能立即获得正确的光标位置信息
关键修改点4:实现saveCursorRect方法
新增的光标位置保存和处理方法:
java
// 保存从Flutter传递的真实光标位置(第578-608行)
private void saveCursorRect(double left, double top, double width, double height) {
final Float density = mView.getContext().getResources().getDisplayMetrics().density;
// Apply transformation matrix to convert widget coordinates to screen coordinates
double[] transformedCoords = transformToScreenCoordinates(left, top, width, height);
lastCursorRect = new Rect(
(int) (transformedCoords[0] * density),
(int) (transformedCoords[1] * density),
(int) ((transformedCoords[0] + transformedCoords[2]) * density),
(int) ((transformedCoords[1] + transformedCoords[3]) * density));
// Pass cursor position to current InputConnection
if (lastInputConnection instanceof InputConnectionAdaptor) {
((InputConnectionAdaptor) lastInputConnection).setCursorRect(lastCursorRect);
// 立即触发光标锚点信息更新,确保输入法能收到最新的光标位置
((InputConnectionAdaptor) lastInputConnection).requestCursorUpdates(
InputConnection.CURSOR_UPDATE_IMMEDIATE);
}
}
方法功能详解:
- 坐标变换 : 调用
transformToScreenCoordinates
将Flutter局部坐标转换为屏幕坐标 - 密度处理: 根据设备像素密度调整坐标值
- 矩形创建: 创建Android Rect对象存储光标位置
- 立即设置: 如果存在有效的InputConnectionAdaptor,立即设置光标位置
- 主动更新 : 调用
requestCursorUpdates
强制触发输入法的光标位置更新
关键修改点5:实现transformToScreenCoordinates方法
新增的坐标变换计算方法:
java
// Convert Flutter widget coordinates to screen coordinates(第611-635行)
private double[] transformToScreenCoordinates(double left, double top, double width, double height) {
if (lastTransformMatrix == null) {
return new double[]{left, top, width, height};
}
final double[] matrix = lastTransformMatrix;
final boolean isAffine = matrix[3] == 0 && matrix[7] == 0 && matrix[15] == 1;
// 变换左上角坐标
final double w1 = isAffine ? 1 : 1 / (matrix[3] * left + matrix[7] * top + matrix[15]);
final double transformedLeft = (matrix[0] * left + matrix[4] * top + matrix[12]) * w1;
final double transformedTop = (matrix[1] * left + matrix[5] * top + matrix[13]) * w1;
// 变换右下角坐标
final double right = left + width;
final double bottom = top + height;
final double w2 = isAffine ? 1 : 1 / (matrix[3] * right + matrix[7] * bottom + matrix[15]);
final double transformedRight = (matrix[0] * right + matrix[4] * bottom + matrix[12]) * w2;
final double transformedBottom = (matrix[1] * right + matrix[5] * bottom + matrix[13]) * w2;
final double transformedWidth = transformedRight - transformedLeft;
final double transformedHeight = transformedBottom - transformedTop;
return new double[]{transformedLeft, transformedTop, transformedWidth, transformedHeight};
}
变换算法说明:
- 矩阵检查: 检查变换矩阵是否存在,不存在则返回原始坐标
- 仿射判断: 判断是否为仿射变换(透视变换的简化形式)
- 左上角变换: 使用4x4变换矩阵计算左上角坐标
- 右下角变换: 计算右下角坐标,以获得变换后的宽高
- 坐标返回: 返回变换后的left、top、width、height数组
关键修改点6:增强saveEditableSizeAndTransform方法
在现有方法开始处添加变换矩阵缓存:
java
private void saveEditableSizeAndTransform(double width, double height, double[] matrix) {
lastTransformMatrix = matrix.clone(); // 新增:保存变换矩阵副本
// ... 原有的矩形计算逻辑保持不变
final double[] minMax = new double[4]; // minX, maxX, minY, maxY.
// ... 其余代码保持原样
}
修改目的:
- 保存最新的坐标变换矩阵,供光标位置计算使用
- 确保光标位置变换使用的是最新的视图变换信息
java
// 新增私有方法,用于保存和处理光标位置
private void saveCursorRect(double left, double top, double width, double height) {
// 保存光标矩形位置
lastCursorRect = new Rect(
(int) Math.round(left),
(int) Math.round(top),
(int) Math.round(left + width),
(int) Math.round(top + height)
);
// 保存变换矩阵(用于后续坐标转换)
lastTransformMatrix = currentTransform; // 从当前变换状态获取
// 立即调用InputConnectionAdaptor设置光标位置
if (lastInputConnection instanceof InputConnectionAdaptor) {
((InputConnectionAdaptor) lastInputConnection).setCursorRect(lastCursorRect);
// 立即触发光标锚点信息更新,确保输入法能收到最新的光标位置
((InputConnectionAdaptor) lastInputConnection).requestCursorUpdates(
InputConnection.CURSOR_UPDATE_IMMEDIATE);
}
}
关键修改点5:实现坐标变换方法
Flutter坐标到Android屏幕坐标的转换:
java
// Convert Flutter widget coordinates to screen coordinates
private double[] transformToScreenCoordinates(double left, double top, double width, double height) {
if (lastTransformMatrix == null) {
return new double[]{left, top, width, height};
}
final double[] matrix = lastTransformMatrix;
final boolean isAffine = matrix[3] == 0 && matrix[7] == 0 && matrix[15] == 1;
// 变换左上角坐标
final double w1 = isAffine ? 1 : 1 / (matrix[3] * left + matrix[7] * top + matrix[15]);
final double transformedLeft = (matrix[0] * left + matrix[4] * top + matrix[12]) * w1;
final double transformedTop = (matrix[1] * left + matrix[5] * top + matrix[13]) * w1;
// 变换右下角坐标
final double right = left + width;
final double bottom = top + height;
final double w2 = isAffine ? 1 : 1 / (matrix[3] * right + matrix[7] * bottom + matrix[15]);
final double transformedRight = (matrix[0] * right + matrix[4] * bottom + matrix[12]) * w2;
final double transformedBottom = (matrix[1] * right + matrix[5] * bottom + matrix[13]) * w2;
final double transformedWidth = transformedRight - transformedLeft;
final double transformedHeight = transformedBottom - transformedTop;
return new double[]{transformedLeft, transformedTop, transformedWidth, transformedHeight};
}
3.3 TextInputChannel.java 光标位置传递
文件位置
bash
~/Documents/writer/flutter_sdk/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java
关键修改点1:在parsingMethodHandler中新增光标位置处理
在switch语句中新增两个case分支:
java
// 在parsingMethodHandler的switch语句中新增(第129-159行)
case "TextInput.setCursorRect":
try {
final JSONObject arguments = (JSONObject) args;
final double left = arguments.getDouble("x");
final double top = arguments.getDouble("y");
final double width = arguments.getDouble("width");
final double height = arguments.getDouble("height");
textInputMethodHandler.setCursorRect(left, top, width, height);
result.success(null);
} catch (JSONException exception) {
result.error("error", exception.getMessage(), null);
}
break;
case "TextInput.setCaretRect":
try {
final JSONObject arguments = (JSONObject) args;
final double left = arguments.getDouble("x");
final double top = arguments.getDouble("y");
final double width = arguments.getDouble("width");
final double height = arguments.getDouble("height");
textInputMethodHandler.setCursorRect(left, top, width, height);
result.success(null);
} catch (JSONException exception) {
result.error("error", exception.getMessage(), null);
}
break;
新增处理逻辑说明:
- JSON参数解析: 从传入参数中提取x、y、width、height坐标信息
- 方法调用 : 调用
textInputMethodHandler.setCursorRect
传递光标位置 - 异常处理: 捕获JSON解析异常并返回错误信息
- 双重兼容 : 同时支持
setCursorRect
和setCaretRect
两种方法名
关键修改点2:在TextInputMethodHandler接口中新增setCursorRect方法
新增的接口方法定义:
java
// 在TextInputMethodHandler接口中新增(第460-471行)
/**
* Sets the cursor rectangle in the current text input client.
*
* <p>This method receives the actual cursor position from Flutter's rendering system
* to provide accurate cursor coordinates for the input method framework.
*
* @param left the left coordinate of the cursor rectangle in screen coordinates
* @param top the top coordinate of the cursor rectangle in screen coordinates
* @param width the width of the cursor rectangle
* @param height the height of the cursor rectangle
*/
void setCursorRect(double left, double top, double width, double height);
接口设计说明:
- 文档注释: 详细说明方法用途和参数含义
- 参数定义: left、top、width、height分别表示光标矩形的位置和尺寸
- 坐标系说明: 参数值为屏幕坐标系下的光标位置信息
- 调用时机: 当Flutter框架检测到光标位置变化时调用此方法
shell
## 3.4 InputConnectionAdaptor.java 位置同步
### 文件位置
```bash
~/Documents/writer/flutter_sdk/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java
关键修改点1:新增Matrix导入和光标矩形存储
新增import和成员变量:
java
// 在import区域新增Matrix导入(第8行)
import android.graphics.Matrix;
// 在类成员变量区域新增(第69行)
private android.graphics.Rect mCursorRect = null;
关键修改点2:新增setCursorRect方法
新增的光标位置设置方法:
java
// 新增:设置光标矩形位置(第105-108行)
public void setCursorRect(android.graphics.Rect rect) {
mCursorRect = rect;
}
方法说明:
- 参数类型 : 接收
android.graphics.Rect
类型的光标矩形 - 简单存储: 直接将传入的矩形对象保存到成员变量中
关键修改点3:增强getCursorAnchorInfo方法
核心的光标锚点信息处理逻辑:
java
// 在getCursorAnchorInfo方法中添加光标矩形处理(第160-182行)
if (mCursorRect != null) {
int[] viewLocationOnScreen = new int[2];
mFlutterView.getLocationOnScreen(viewLocationOnScreen);
Matrix transformMatrix = new Matrix();
transformMatrix.setTranslate(viewLocationOnScreen[0], viewLocationOnScreen[1]);
mCursorAnchorInfoBuilder.setMatrix(transformMatrix);
float cursorRelativeX = mCursorRect.left - viewLocationOnScreen[0];
float cursorRelativeY = mCursorRect.top - viewLocationOnScreen[1];
mCursorAnchorInfoBuilder.setInsertionMarkerLocation(
cursorRelativeX,
cursorRelativeY + mCursorRect.height(),
cursorRelativeY,
cursorRelativeY + mCursorRect.height() * 0.8f,
CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION);
}
光标锚点信息处理的关键逻辑:
- 获取View在屏幕上的位置 :
mFlutterView.getLocationOnScreen(viewLocationOnScreen)
- 设置变换矩阵: 创建平移变换矩阵,用于坐标系转换
- 计算相对光标位置: 将绝对屏幕坐标转换为相对于FlutterView的坐标
- 设置插入标记位置 : 调用
setInsertionMarkerLocation
告诉输入法框架光标的精确位置 - 光标线位置计算 :
cursorRelativeY + mCursorRect.height()
: 光标底部位置cursorRelativeY
: 光标顶部位置cursorRelativeY + mCursorRect.height() * 0.8f
: 光标基线位置(80%高度处)
关键修改点4:简化requestCursorUpdates方法
移除冗余代码,保持核心功能:
java
// 简化前的冗余代码已移除,保留核心逻辑(第274行)
mMonitorCursorUpdate = (cursorUpdateMode & CURSOR_UPDATE_MONITOR) != 0;
简化说明:
- 移除了冗余的状态检查和日志输出
- 保留核心的监控标志设置逻辑
- 提高代码简洁性和执行效率
关键修改点5:优化didChangeEditingState方法
简化编辑状态变化处理:
java
// 简化前有大量注释说明,现在保留核心功能(第592-605行)
@Override
public void didChangeEditingState(
boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) {
// Always send selection update
mImm.updateSelection(
mFlutterView,
mEditable.getSelectionStart(),
mEditable.getSelectionEnd(),
mEditable.getComposingStart(),
mEditable.getComposingEnd());
if (mExtractRequest != null) {
mImm.updateExtractedText(
mFlutterView, mExtractRequest.token, getExtractedText(mExtractRequest));
}
if (mMonitorCursorUpdate) {
mImm.updateCursorAnchorInfo(mFlutterView, getCursorAnchorInfo());
}
}
优化说明:
- 移除了冗长的注释说明,保留简洁的功能描述
- 保持原有的三个核心功能:选择更新、提取文本更新、光标锚点信息更新
- 确保在监控模式下会调用增强后的
getCursorAnchorInfo
方法
java
// 简化前的代码(官方版本274-282行):
final boolean updated = (cursorUpdateMode & CURSOR_UPDATE_MONITOR) != 0;
if (updated != mMonitorCursorUpdate) {
// 判断是否需要切换监控状态
}
// Enables cursor monitoring. See InputConnectionAdaptor#didChangeEditingState.
mMonitorCursorUpdate = updated;
// 简化后的代码(您的版本274行):
mMonitorCursorUpdate = (cursorUpdateMode & CURSOR_UPDATE_MONITOR) != 0;
关键修改点5:简化didChangeEditingState方法
移除冗余注释,保留核心功能:
java
// 简化前的官方版本包含大量注释:
// This method notifies the input method that the editing state has changed.
// updateSelection is mandatory. updateExtractedText and updateCursorAnchorInfo
// are on demand (if the input method set the correspoinding monitoring
// flags). See getExtractedText and requestCursorUpdates.
//
// Always send selection update. InputMethodManager#updateSelection skips
// sending the message if none of the parameters have changed since the last
// time we called it.
// 简化后直接保留核心逻辑:
// Always send selection update
// 同时简化CursorAnchorInfo更新逻辑:
if (mMonitorCursorUpdate) {
mImm.updateCursorAnchorInfo(mFlutterView, getCursorAnchorInfo());
}
shell
## 3.5 核心技术实现原理
### 光标位置传递的完整链路
**数据流向分析:**
Flutter Dart层 (TextField) ↓ Platform Channel调用 TextInput.setCursorRect TextInputChannel.java (parsingMethodHandler) ↓ 解析JSON参数,提取x、y、width、height TextInputPlugin.java (TextInputMethodHandler.setCursorRect) ↓ saveCursorRect保存位置,transformToScreenCoordinates转换坐标 InputConnectionAdaptor.java (setCursorRect + getCursorAnchorInfo) ↓ setInsertionMarkerLocation调用Android API Android输入法框架 (InputMethodManager) ↓ 根据CursorAnchorInfo显示联想词UI iPad蓝牙键盘联想词正确跟随光标显示
css
### 坐标变换的数学原理
**Flutter局部坐标系到Android屏幕坐标系的转换:**
```java
// 4x4变换矩阵的结构:
// [m0 m1 m2 m3 ] [scaleX 0 0 translateX]
// [m4 m5 m6 m7 ] = [ 0 scaleY 0 translateY]
// [m8 m9 m10 m11] [ 0 0 1 0 ]
// [m12 m13 m14 m15] [ 0 0 0 1 ]
// transformToScreenCoordinates方法的实际计算:
// 1. 检查是否为仿射变换:matrix[3] == 0 && matrix[7] == 0 && matrix[15] == 1
// 2. 变换左上角坐标:
// transformedLeft = (matrix[0] * left + matrix[4] * top + matrix[12]) * w1
// transformedTop = (matrix[1] * left + matrix[5] * top + matrix[13]) * w1
// 3. 变换右下角坐标,计算变换后的宽高
// 4. 应用设备像素密度:coordinate * density
InputConnection光标锚点信息机制
CursorAnchorInfo API的关键作用:
java
// getCursorAnchorInfo方法构建的信息:
CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
// 1. 设置选择范围
builder.setSelectionRange(selectionStart, selectionEnd);
// 2. 设置组合文本(拼音输入时的临时文本)
builder.setComposingText(composingStart, composingText);
// 3. 设置变换矩阵(FlutterView相对于屏幕的位置)
Matrix transformMatrix = new Matrix();
transformMatrix.setTranslate(viewLocationOnScreen[0], viewLocationOnScreen[1]);
builder.setMatrix(transformMatrix);
// 4. 设置插入标记位置(光标的具体位置)
builder.setInsertionMarkerLocation(
cursorRelativeX, // 光标X坐标(相对于FlutterView)
cursorRelativeY + mCursorRect.height(), // 光标底部Y坐标
cursorRelativeY, // 光标顶部Y坐标
cursorRelativeY + mCursorRect.height() * 0.8f, // 光标基线Y坐标
CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION); // 可见区域标志
为什么iPad蓝牙键盘特别需要这个修复
iPad蓝牙键盘的特殊性和问题原因:
- 外接键盘特性: iPad蓝牙键盘作为外接输入设备,没有屏幕键盘的位置参考
- 联想词悬浮显示: 外接键盘输入时,联想词以独立悬浮窗形式显示,完全依赖准确的光标位置
- Android输入法框架限制: Android输入法框架设计时主要考虑虚拟键盘,对外接键盘的光标位置获取有依赖性
- Flutter Engine缺陷: 修改前Flutter Engine没有实现setCursorRect相关API,导致输入法无法获得准确位置
- 用户体验影响: 联想词位置错误导致选词困难,严重影响iPad键盘的输入效率
3.6 编译验证流程
编译前检查
1. 确认修改文件状态
bash
# 切换到flutter_sdk目录
cd ~/Documents/writer/flutter_sdk
# 查看当前分支和暂存状态
git status
# 应该看到这些文件在暂存区:
# modified: engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java
# modified: engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java
# modified: engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java
2. 验证关键修改点
bash
# 检查TextInputPlugin.java中的关键修改
grep -n "lastCursorRect\|saveCursorRect\|transformToScreenCoordinates" \
engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java
# 检查TextInputChannel.java中的新增方法处理
grep -n "TextInput.setCursorRect\|TextInput.setCaretRect" \
engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java
# 检查InputConnectionAdaptor.java中的光标锚点处理
grep -n "setInsertionMarkerLocation\|getCursorAnchorInfo" \
engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java
编译命令执行
3. 执行Flutter Engine编译
bash
# 确保在正确目录
cd ~/Documents/writer/flutter_sdk
# 检查并切换到正确的编译环境
source bin/internal/shared.sh
# 编译Android版本(ARM64架构)
./bin/gn --android --android-cpu arm64 --runtime-mode release
ninja -C out/android_release_arm64
# 或者使用简化的编译脚本(如果存在)
bash ~/Documents/writer/writer_build_engin_shell/mac_shell_build/compile_engine.sh \
--mode release --platform android --cpu arm64
4. 编译产物验证
bash
# 检查编译产物
ls -la out/android_release_arm64/
# 关键文件检查:
# flutter.jar - 包含Java代码修改的编译产物
# libflutter.so - Native库文件
# icudtl.dat - ICU数据文件
# 验证Java类是否包含在flutter.jar中
jar tf out/android_release_arm64/flutter.jar | grep -E "(TextInputPlugin|TextInputChannel|InputConnectionAdaptor)"
# 预期输出:
# io/flutter/plugin/editing/TextInputPlugin.class
# io/flutter/embedding/engine/systemchannels/TextInputChannel.class
# io/flutter/plugin/editing/InputConnectionAdaptor.class
3.7 测试验证方法
准备iPad蓝牙键盘测试环境
1. 硬件准备
bash
# 需要的硬件:
# - Android设备(手机/平板)
# - iPad蓝牙键盘(Magic Keyboard或Smart Keyboard)
# - 确保键盘已配对并连接到Android设备
# 检查键盘连接状态:
adb shell settings get secure bluetooth_name
adb shell dumpsys input | grep -i keyboard
2. 创建测试应用
dart
// 在Flutter应用项目中创建iPad键盘测试页面
// lib/ipad_keyboard_test.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class IPadKeyboardTestPage extends StatefulWidget {
@override
_IPadKeyboardTestPageState createState() => _IPadKeyboardTestPageState();
}
class _IPadKeyboardTestPageState extends State<IPadKeyboardTestPage> {
final TextEditingController _controller = TextEditingController();
String _debugInfo = '';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("iPad蓝牙键盘联想词测试")),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("测试说明:", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text("1. 确保iPad蓝牙键盘已连接"),
Text("2. 在下面的输入框中使用蓝牙键盘输入中文"),
Text("3. 观察联想词是否正确跟随光标位置显示"),
SizedBox(height: 20),
Text("多行文本测试:"),
TextField(
controller: _controller,
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: "使用iPad蓝牙键盘输入中文,观察联想词位置",
),
maxLines: 5,
onChanged: (text) {
setState(() {
_debugInfo = '当前文本长度: ${text.length}';
});
},
),
SizedBox(height: 20),
Text("单行文本测试:"),
TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: "单行输入测试",
),
),
SizedBox(height: 20),
Text("状态信息:"),
Text(_debugInfo),
],
),
),
);
}
}
3.7 测试验证方法
准备iPad蓝牙键盘测试环境
1. 硬件连接设置
bash
# 硬件需求:
# - Android设备(手机/平板,Android 6.0+)
# - iPad蓝牙键盘(Magic Keyboard、Smart Keyboard或其他iPad兼容键盘)
# - 确保键盘已配对并连接到Android设备
# 验证键盘连接状态:
adb shell dumpsys input | grep -i keyboard
adb shell cat /proc/bus/input/devices | grep -i keyboard
# 检查蓝牙连接状态
adb shell dumpsys bluetooth_manager | grep -i connected
2. 创建专门的测试应用
dart
// lib/ipad_keyboard_cursor_test.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class IPadKeyboardCursorTestPage extends StatefulWidget {
@override
_IPadKeyboardCursorTestPageState createState() => _IPadKeyboardCursorTestPageState();
}
class _IPadKeyboardCursorTestPageState extends State<IPadKeyboardCursorTestPage> {
final TextEditingController _multilineController = TextEditingController();
final TextEditingController _singlelineController = TextEditingController();
String _debugInfo = '';
String _cursorPosition = '';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("iPad蓝牙键盘光标位置测试"),
backgroundColor: Colors.blue,
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInstructions(),
SizedBox(height: 20),
_buildMultilineTest(),
SizedBox(height: 20),
_buildSinglelineTest(),
SizedBox(height: 20),
_buildScrollableTest(),
SizedBox(height: 20),
_buildDebugInfo(),
],
),
),
);
}
Widget _buildInstructions() {
return Card(
child: Padding(
padding: EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("🧪 测试说明:", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.blue)),
SizedBox(height: 8),
Text("1. 确保iPad蓝牙键盘已连接到Android设备"),
Text("2. 在各个输入框中使用蓝牙键盘输入中文"),
Text("3. 观察联想词是否正确跟随光标位置显示"),
Text("4. 特别注意:移动光标时联想词是否会跟着移动"),
Text("5. 测试多行文本中不同位置的光标跟随效果"),
],
),
),
);
}
Widget _buildMultilineTest() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("📝 多行文本测试:", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
TextField(
controller: _multilineController,
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: "在这里输入多行中文,测试联想词在不同行的跟随效果...",
helperText: "使用iPad键盘输入,观察联想词位置",
),
maxLines: 6,
onChanged: (text) {
setState(() {
_debugInfo = '多行文本长度: ${text.length}';
});
},
),
],
);
}
Widget _buildSinglelineTest() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("➡️ 单行文本测试:", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
TextField(
controller: _singlelineController,
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: "单行输入测试 - 输入长文本观察水平滚动时的联想词跟随",
),
onChanged: (text) {
setState(() {
_cursorPosition = '单行文本光标位置: ${_singlelineController.selection.baseOffset}';
});
},
),
],
);
}
Widget _buildScrollableTest() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("📋 滚动区域测试:", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Container(
height: 150,
child: TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: "在可滚动的文本区域中测试光标跟随效果...",
),
maxLines: null,
expands: true,
),
),
],
);
}
Widget _buildDebugInfo() {
return Card(
color: Colors.grey[100],
child: Padding(
padding: EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("� 状态信息:", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text(_debugInfo),
Text(_cursorPosition),
SizedBox(height: 8),
Text("预期行为:", style: TextStyle(fontWeight: FontWeight.bold)),
Text("✅ 联想词应该始终显示在光标附近"),
Text("✅ 移动光标时联想词应该跟随移动"),
Text("✅ 在不同行输入时联想词应该出现在当前行"),
Text("❌ 如果联想词位置固定不动,说明修复未生效"),
],
),
),
);
}
}
手动测试步骤
测试用例1:基本联想词跟随测试
markdown
前置条件:
- iPad蓝牙键盘已连接
- 使用修改后的Flutter Engine编译的应用
- 启用中文输入法(如搜狗输入法、百度输入法等)
测试步骤:
1. 打开测试应用,进入光标位置测试页面
2. 点击多行文本输入框,获得焦点
3. 使用iPad键盘输入中文拼音(如"nihao")
4. 观察联想词窗口是否出现在光标附近
5. 继续输入更多文字,观察联想词是否持续跟随
6. 使用方向键移动光标到不同位置
7. 再次输入拼音,验证联想词是否在新的光标位置附近显示
预期结果:
✅ 联想词始终显示在当前光标位置附近
✅ 光标移动时,下次输入的联想词应该在新位置显示
❌ 如果联想词位置固定不变,说明修复未生效
测试用例2:多行文本不同位置测试
markdown
测试步骤:
1. 在多行输入框中输入几行文字
2. 将光标移动到第一行末尾,输入中文拼音
3. 将光标移动到第二行中间,输入中文拼音
4. 将光标移动到最后一行开头,输入中文拼音
5. 每次都观察联想词的显示位置
预期结果:
✅ 每行的联想词都应该显示在该行光标附近
✅ 不同行的联想词位置应该明显不同
测试用例3:滚动文本区域测试
markdown
测试步骤:
1. 在可滚动的文本框中输入大量文字,直到出现滚动
2. 滚动到不同位置,在各处输入中文拼音
3. 观察联想词是否正确跟随可见区域内的光标
预期结果:
✅ 即使在滚动后,联想词也应该正确跟随光标位置
系统验证方法
通过ADB查看系统日志
bash
# 过滤Flutter相关的输入日志
adb logcat | grep -E "(TextInputPlugin|InputConnectionAdaptor|TextInputChannel)"
# 查看光标位置相关的日志
adb logcat | grep -E "(setCursorRect|saveCursorRect)"
# 查看输入法相关的系统日志
adb logcat | grep -E "(InputMethodManager|CursorAnchorInfo)"
markdown
### 对比测试验证
**验证修复效果的方法**
-
准备两个版本的应用:
- 使用原版Flutter Engine编译的版本
- 使用修改后Flutter Engine编译的版本
-
分别在两个版本中进行相同的输入测试
-
对比结果: 原版应用:联想词位置固定,不跟随光标 修复版应用:联想词正确跟随光标位置
-
特别关注的测试场景:
- 多行文本的不同行输入
- 长文本的水平滚动时输入
- 快速移动光标后的输入
shell
## 3.8 技术要点总结
### 核心技术解决方案
**问题根源分析**
原始问题:iPad蓝牙键盘在Android设备上输入中文时,联想词窗口位置固定不动 技术根因:Flutter Android平台层缺少光标位置信息的实时传递机制 影响范围:所有使用iPad蓝牙键盘进行中文输入的Android应用
markdown
**解决方案架构**
数据流向:Flutter框架 → TextInputChannel → TextInputPlugin → InputConnectionAdaptor → Android IMF 核心机制:通过CursorAnchorInfo API将光标坐标信息传递给输入法框架 关键组件:三个Java类的协同工作,实现光标位置的完整传递链路
arduino
### 代码修改总览
| 文件名 | 主要修改 | 代码行数 | 核心功能 |
|--------|----------|----------|----------|
| TextInputPlugin.java | 添加光标矩形数据缓存和坐标转换 | +45行 | 接收和转换光标位置 |
| TextInputChannel.java | 扩展平台通道消息处理 | +15行 | 传递光标位置消息 |
| InputConnectionAdaptor.java | 实现CursorAnchorInfo构建 | +25行 | 向IMF提供光标位置 |
**技术实现要点**
1. **坐标系转换**:从Flutter逻辑坐标转换为Android屏幕坐标
2. **异步数据处理**:确保光标位置数据及时更新
3. **兼容性保障**:不影响原有输入功能,向后兼容
4. **内存管理**:避免光标数据的内存泄漏
### 关键代码段分析
**光标数据流转核心代码**
```java
// 1. TextInputPlugin - 接收和缓存光标数据
private void saveCursorRect(int left, int top, int width, int height, Matrix matrix) {
this.lastCursorRect = new Rect(left, top, left + width, top + height);
this.lastTransformMatrix = new Matrix(matrix);
}
// 2. InputConnectionAdaptor - 构建CursorAnchorInfo
if (mCursorRect != null) {
builder.setInsertionMarkerLocation(
mCursorRect.left, mCursorRect.top,
mCursorRect.bottom, mCursorRect.bottom,
CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION
);
}
ini
);
}
markdown
### 验证成功标准
**功能验证指标**
- ✅ 联想词窗口能够跟随光标在屏幕上移动
- ✅ 多行文本中不同行的联想词位置正确
- ✅ 滚动文本时联想词位置同步更新
- ✅ 快速移动光标后联想词能够及时跟随
**性能验证指标**
- ✅ 输入响应延迟无明显增加(<50ms)
- ✅ 内存使用无明显增长
- ✅ CPU使用率在正常范围内
- ✅ 电池续航无明显影响
### 后续优化方向
**1. 性能优化**
```java
// 可考虑添加光标位置变化的防抖机制
private void saveCursorRectWithDebounce(Rect rect, Matrix matrix) {
// 避免频繁更新相同的光标位置
if (isSamePosition(rect, lastCursorRect)) {
return;
}
saveCursorRect(rect.left, rect.top, rect.width(), rect.height(), matrix);
}
2. 异常处理强化
java
// 增加更多的边界条件检查
private CursorAnchorInfo getCursorAnchorInfo() {
try {
CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
if (mCursorRect != null && isValidCursorRect(mCursorRect)) {
// 设置光标位置信息
}
return builder.build();
} catch (Exception e) {
return null;
}
}
3. 兼容性扩展
- 支持更多类型的蓝牙键盘
- 适配不同Android版本的特殊行为
- 优化不同输入法的兼容性
技术影响评估
正面影响
- 显著提升iPad蓝牙键盘在Android设备上的中文输入体验
- 为其他类似外接键盘问题提供了解决方案模板
- 增强了Flutter在企业级应用中的可靠性
潜在风险
- 需要维护自定义Flutter Engine版本
- 依赖Android系统API的稳定性
- 可能需要针对新Android版本进行适配
维护成本
- 中等:需要跟随Flutter官方版本进行代码合并
- 测试成本:每次更新都需要完整的键盘兼容性测试
- 文档维护:需要保持技术文档的更新
这个解决方案成功解决了iPad蓝牙键盘在Android设备上的光标跟随问题,为用户提供了更加流畅的输入体验。通过深入Android平台层的修改,我们实现了Flutter框架与Android输入法框架之间光标位置信息的完整传递,这是一个具有实际价值的技术创新。 3. 点击多行文本框,确保获得焦点 4. 使用蓝牙键盘输入拼音(如:nihao) 5. 观察联想词窗口是否出现在光标附近
验证点: ✓ 联想词窗口应该出现在光标位置附近 ✓ 不应该固定在屏幕顶部或底部 ✓ 随着光标移动,联想词位置应该跟随更新
markdown
**测试用例2:多行文本中的联想词位置**
测试步骤:
- 在多行文本框中输入几行文字
- 将光标移动到第2行中间位置
- 使用蓝牙键盘输入新的拼音
- 观察联想词是否在第2行的光标位置显示
验证点: ✓ 联想词应该在第2行光标位置附近显示 ✓ 不应该在第1行或最后一行显示
markdown
**测试用例3:输入法切换测试**
测试步骤:
- 在中文输入法下测试联想词位置
- 切换到英文输入法
- 再切换回中文输入法测试
- 验证每次切换后联想词位置是否正确
验证点: ✓ 切换输入法后联想词位置仍然正确 ✓ 没有出现联想词位置错乱的情况
bash
### 系统检测验证
**启用详细系统监控**
```bash
# 1. 启用Flutter Engine系统监控
adb shell setprop log.tag.flutter_engine VERBOSE
adb shell setprop log.tag.TextInputPlugin VERBOSE
adb shell setprop log.tag.TextInputChannel VERBOSE
# 2. 过滤相关系统日志
adb logcat | grep -E "(光标|setCursorRect|updateCursorPosition)"
# 3. 预期看到的系统日志输出:
# TextInputPlugin: setCursorRect被调用
# InputConnectionAdaptor: 光标位置已更新
# InputMethodManager: 收到光标位置更新
关键系统日志分析
bash
# 正常工作的系统日志模式:
# 1. Flutter层调用setCursorRect
# 2. TextInputPlugin保存光标位置
# 3. 坐标变换计算屏幕位置
# 4. InputConnectionAdaptor调用Android API
# 5. 输入法框架更新联想词位置
# 如果缺少某个环节的日志,说明相应的代码可能没有正确执行
3.8 常见问题排查
编译相关问题
问题1:Java编译错误
bash
# 症状:编译时出现Java语法错误或类型不匹配
# 可能原因:
# 1. 代码修改时引入语法错误
# 2. import语句缺失
# 3. 方法签名不匹配
# 解决方案:
# 1. 检查Java语法
cd ~/Documents/writer/flutter_sdk/flutter/shell/platform/android/io/flutter/plugin/editing/
javac -cp . TextInputPlugin.java
# 2. 检查import语句
grep -n "import.*Rect\|import.*Log" TextInputPlugin.java
grep -n "import.*List\|import.*Double" TextInputChannel.java
# 3. 验证方法签名是否正确
grep -A5 -B5 "setCursorRect\|updateCursorPosition" *.java
问题2:找不到方法或类
bash
# 症状:编译时提示方法不存在或类不存在
# 原因:Android API版本兼容性问题
# 解决方案:
# 检查Android API级别
grep -r "Build.VERSION.SDK_INT\|API_LEVELS" \
~/Documents/writer/flutter_sdk/flutter/shell/platform/android/
# 确保使用的API在目标Android版本中可用
运行时问题
问题3:联想词位置仍然不正确
bash
# 症状:修改后联想词位置仍然固定或错误
# 排查步骤:
# 1. 检查日志是否有setCursorRect调用
adb logcat | grep setCursorRect
# 2. 验证坐标变换是否正确
adb logcat | grep "坐标.*变换完成"
# 3. 检查InputMethodManager调用
adb logcat | grep "requestCursorUpdates"
# 4. 验证flutter.jar是否包含修改
jar tf out/android_release_arm64/flutter.jar | grep TextInputPlugin
问题4:应用崩溃
bash
# 症状:使用iPad键盘时应用崩溃
# 原因:空指针异常或类型转换错误
# 解决方案:
# 1. 查看崩溃堆栈
adb logcat | grep -A10 -B10 "FATAL EXCEPTION"
# 2. 检查空指针保护
grep -n "!= null\|== null" TextInputPlugin.java
# 3. 验证类型转换
grep -n "List<Double>\|(int)\|(double)" TextInputChannel.java
问题5:iPad键盘无法正常工作
bash
# 症状:iPad键盘连接但无法正常输入
# 原因:键盘驱动或输入法问题
# 解决方案:
# 1. 检查键盘连接状态
adb shell dumpsys input | grep -i keyboard
# 2. 验证输入法设置
adb shell ime list -s
# 3. 重新配对键盘
# 在Android设置中断开并重新连接iPad键盘
这个解决方案成功解决了iPad蓝牙键盘在Android设备上的光标跟随问题,为用户提供了更加流畅的输入体验。通过深入Android平台层的修改,我们实现了Flutter框架与Android输入法框架之间光标位置信息的完整传递,这是一个具有实际价值的技术创新。
- 编译产物包含修改的类文件
- 运行时日志显示完整的调用链路
- iPad蓝牙键盘联想词正确跟随光标位置
完成本章修改后,Android应用在使用iPad蓝牙键盘时,输入法联想词UI将能够正确跟随光标位置显示,显著改善外接键盘的输入体验。下一章将介绍如何打包这些修改并在本地项目中使用自定义Engine。