第三章: 解决Android iPad蓝牙键盘联想词UI不跟随光标问题

快速开始 - 问题概述

问题描述

在Flutter Android应用中,当使用iPad蓝牙键盘进行文本输入时,输入法的联想词UI(候选词窗口)无法正确跟随光标位置显示,而是固定在屏幕的某个位置,严重影响用户的输入体验。

问题的具体表现

  • 使用iPad蓝牙键盘输入文本时
  • 输入法联想词窗口位置固定,不跟随光标移动
  • 联想词UI可能遮挡输入内容或显示在错误位置
  • 影响用户选择联想词和查看输入内容

解决方案概览

通过修改Flutter Engine中的三个关键文件,实现输入法联想词UI正确跟随光标位置:

  1. InputConnectionAdaptor.java - 输入连接适配器(添加光标位置同步)
  2. TextInputChannel.java - 文本输入通道(增强光标位置传递)
  3. 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后立即检查是否有缓存的光标位置
  • 如果有缓存的光标位置,立即调用setCursorRectrequestCursorUpdates
  • 确保新建的输入连接能立即获得正确的光标位置信息

关键修改点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);
  }
}

方法功能详解:

  1. 坐标变换 : 调用transformToScreenCoordinates将Flutter局部坐标转换为屏幕坐标
  2. 密度处理: 根据设备像素密度调整坐标值
  3. 矩形创建: 创建Android Rect对象存储光标位置
  4. 立即设置: 如果存在有效的InputConnectionAdaptor,立即设置光标位置
  5. 主动更新 : 调用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};
}

变换算法说明:

  1. 矩阵检查: 检查变换矩阵是否存在,不存在则返回原始坐标
  2. 仿射判断: 判断是否为仿射变换(透视变换的简化形式)
  3. 左上角变换: 使用4x4变换矩阵计算左上角坐标
  4. 右下角变换: 计算右下角坐标,以获得变换后的宽高
  5. 坐标返回: 返回变换后的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解析异常并返回错误信息
  • 双重兼容 : 同时支持setCursorRectsetCaretRect两种方法名

关键修改点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);
}

光标锚点信息处理的关键逻辑:

  1. 获取View在屏幕上的位置 : mFlutterView.getLocationOnScreen(viewLocationOnScreen)
  2. 设置变换矩阵: 创建平移变换矩阵,用于坐标系转换
  3. 计算相对光标位置: 将绝对屏幕坐标转换为相对于FlutterView的坐标
  4. 设置插入标记位置 : 调用setInsertionMarkerLocation告诉输入法框架光标的精确位置
  5. 光标线位置计算 :
    • 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蓝牙键盘的特殊性和问题原因:

  1. 外接键盘特性: iPad蓝牙键盘作为外接输入设备,没有屏幕键盘的位置参考
  2. 联想词悬浮显示: 外接键盘输入时,联想词以独立悬浮窗形式显示,完全依赖准确的光标位置
  3. Android输入法框架限制: Android输入法框架设计时主要考虑虚拟键盘,对外接键盘的光标位置获取有依赖性
  4. Flutter Engine缺陷: 修改前Flutter Engine没有实现setCursorRect相关API,导致输入法无法获得准确位置
  5. 用户体验影响: 联想词位置错误导致选词困难,严重影响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 复制代码
### 对比测试验证

**验证修复效果的方法**
  1. 准备两个版本的应用:

    • 使用原版Flutter Engine编译的版本
    • 使用修改后Flutter Engine编译的版本
  2. 分别在两个版本中进行相同的输入测试

  3. 对比结果: 原版应用:联想词位置固定,不跟随光标 修复版应用:联想词正确跟随光标位置

  4. 特别关注的测试场景:

    • 多行文本的不同行输入
    • 长文本的水平滚动时输入
    • 快速移动光标后的输入
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:多行文本中的联想词位置**

测试步骤:

  1. 在多行文本框中输入几行文字
  2. 将光标移动到第2行中间位置
  3. 使用蓝牙键盘输入新的拼音
  4. 观察联想词是否在第2行的光标位置显示

验证点: ✓ 联想词应该在第2行光标位置附近显示 ✓ 不应该在第1行或最后一行显示

markdown 复制代码
**测试用例3:输入法切换测试**

测试步骤:

  1. 在中文输入法下测试联想词位置
  2. 切换到英文输入法
  3. 再切换回中文输入法测试
  4. 验证每次切换后联想词位置是否正确

验证点: ✓ 切换输入法后联想词位置仍然正确 ✓ 没有出现联想词位置错乱的情况

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。

相关推荐
蒋星熠3 小时前
Flutter跨平台工程实践与原理透视:从渲染引擎到高质产物
开发语言·python·算法·flutter·设计模式·性能优化·硬件工程
卢叁6 小时前
Flutter之自定义TabIndicator
前端·flutter
萧雾宇7 小时前
Android Compose打造仿现实逼真的烟花特效
android·flutter·kotlin
拜无忧8 小时前
【教程】flutter常用知识点总结-针对小白
android·flutter·android studio
拜无忧9 小时前
【教程】Flutter 高性能项目架构创建指南:从入门到高性能架构
android·flutter·android studio
醉过才知酒浓9 小时前
flutter 拦截返回按钮的方法(WillPopScope or PopScope)
flutter
傅里叶11 小时前
sudo启动Flutter程序AMD初始化失败
linux·flutter
苦逼的搬砖工12 小时前
Flutter UI Components:闲来无事,设计整理了这几年来使用的UI组件库
前端·flutter
黑金IT13 小时前
Dart → `.exe`:Flutter 桌面与纯命令行双轨编译完全指南
flutter
iOS_MingXing14 小时前
flutter TabBar 设置isScrollable 第一个有间距
flutter