PAYATHON 2026

Flutter 登录框在 iPad Air M3 上清空,iPhone 和模拟器无法复现

排障赵师傅

正常情况下,Obx 重建整个 Column 不应清空绑定到同一个 TextEditingController 的输入内容。仅凭现有代码,还无法认定这是 iPadOS 26 的缺陷,或 GetX 与自动填充之间的特定冲突。

拆分 Obx 可以减少无关重建,是合理的调整,但不保证能解决文本清空的问题。建议先检查 CommonTitleTextField 的实现,追踪哪些地方改写了 controller 的内容,再确认键盘弹出或状态变化时,输入框是否被移除并重新挂载。

为什么重建通常不会清空文本

Flutter 的 widget 是界面配置。执行 build()、创建新的 widget 对象时,通常会更新已有的 Element 和 State。在树中的对应位置,runtimeType 和 key 相同是复用 Element 的条件,详见 Flutter 的 Widget.canUpdate 文档。

对于显式绑定 controller 的标准 TextField,文本来自 controller.value。即使输入框重新挂载,也应读取该 controller 已有的文本。排查时需要区分:

  • 输入框的焦点、选区或平台输入连接是否发生变化。
  • controller.text 是否真的从非空变成了空字符串。

controller 实例不变,内容仍可能被清空。业务代码调用 clear()、给 text 赋空字符串,或输入系统传来新的编辑值,都可能改变它的内容。TextEditingController 文档说明了文本框与 controller 之间的更新关系。

每次输入都会执行 checkCanLogin(),但 canLogin 的布尔值未必每次都变。因此,每次触发 onChanged,不代表外层 Obx 都会重建。

AutofillGroup 是否存在相关问题

AutofillGroup 把相关输入框放在同一个自动填充范围内,适合邮箱和密码组成的登录表单。它不负责保存文本,也不会阻止重建。

需要检查的是自动填充组的生命周期。顶层 AutofillGroup 被销毁时,默认会提交并结束当前自动填充上下文;正常重建不等于销毁。如果页面通过条件分支反复移除整个表单,就需要检查会话是否因此中断,详见 AutofillGroup 文档。

Flutter 仓库中有 iOS 自动填充相关报告。例如,#148151 涉及多个输入框下保存、更新密码的提示不一致。但这不能证明本例中的“输入时清空”属于同一问题,也不能据此认定原因是 Obx。

切换 obscureText 会改变密码字段的输入配置,可以单独测试。目前还没有足够证据将本例认定为“iPadOS 26 + AutofillGroup + Obx 重建”的已知故障。

先按这个顺序定位

1. 暂时用标准 TextField 替换自定义组件

保留现有 controller 和状态逻辑,只替换两个 CommonTitleTextField,观察问题是否仍然出现,以缩小排查范围。

检查自定义组件内部是否存在以下情况:

  • 实际的 TextField 使用了另建的 controller,没有使用外部传入的 controller。
  • 在 build()、didUpdateWidget() 或校验逻辑中写入空值、默认值或过期的状态值。
  • 在 dispose() 中销毁外部传入、仍需继续使用的 controller。
  • 使用每次都会变化的 UniqueKey(),或把输入内容、密码可见性作为 key。
  • 切换密码显示状态时,替换成结构不同的输入框分支。
  • 自定义 TextInputFormatter 或监听器把输入结果改为空值。

变化的 key 可能导致 State 被替换,但无法单独解释外部 controller 的文本为何变空,还需要追踪赋值过程。

2. 记录文本变空时发生了什么

给两个 controller 添加临时监听,记录时间、identityHashCode(controller)、文本长度、选区和 composing 范围。同时记录焦点变化、路由变化,以及 controller 的 onInit()、onClose()。

不要记录邮箱或密码原文。监听器只用于观察,不要在其中回写文本,用完后及时移除。

根据记录确定排查方向:

观察结果优先检查
controller 实例变化依赖注册、路由绑定、删除与重新创建
同一实例的文本长度变为 0clear()、text / value 赋值、格式化器、平台编辑更新
controller 仍有文本,界面却显示为空自定义组件绑定、显示样式、渲染或输入连接
输入框反复挂载、销毁条件布局、key、加载状态、响应式页面分支

即使只在 main() 中注册过一次,也要确认后续是否调用了 Get.delete()、Get.reset(),或通过其他路径替换了注册对象。

3. 检查键盘弹出后的布局分支

示例没有给出 height 的来源。需要检查它是否依赖 MediaQuery,以及键盘弹出后,页面是否切换到了不同的结构。

普通的尺寸变化不应清空文本。如果尺寸变化导致登录表单被移除、替换,或触发重置逻辑,就需要处理对应逻辑。也要检查 isLoginLoading 是否会把整个表单暂时替换成加载指示器。

如何拆分 Obx

邮箱输入框可以保留在普通布局中,密码输入框只监听密码显示状态,登录按钮只监听可用状态和加载状态。

示例使用标准 TextField,onLogin 接入已有登录方法,AuthenticationController 沿用题目中的定义。

import 'package:flutter/material.dart';
import 'package:get/get.dart';

class LoginFields extends StatelessWidget {
  LoginFields({
    super.key,
    required this.onLogin,
  });

  final VoidCallback onLogin;

  final AuthenticationController c =
      Get.find<AuthenticationController>();

  @override
  Widget build(BuildContext context) {
    return AutofillGroup(
      child: Column(
        children: [
          TextField(
            controller: c.emailTextEditingController,
            keyboardType: TextInputType.emailAddress,
            autofillHints: const [AutofillHints.email],
            autocorrect: false,
            textInputAction: TextInputAction.next,
            decoration: const InputDecoration(
              labelText: '邮箱',
            ),
            onChanged: (_) => c.checkCanLogin(),
          ),
          Obx(() {
            // 沿用原字段名:true 实际表示隐藏密码。
            final obscured = c.isLoginPasswordVisible.value;

            return TextField(
              controller: c.passwordTextEditingController,
              obscureText: obscured,
              keyboardType: TextInputType.visiblePassword,
              autofillHints: const [AutofillHints.password],
              autocorrect: false,
              enableSuggestions: false,
              textInputAction: TextInputAction.done,
              decoration: InputDecoration(
                labelText: '密码',
                suffixIcon: IconButton(
                  tooltip: obscured ? '显示密码' : '隐藏密码',
                  icon: Icon(
                    obscured
                        ? Icons.visibility
                        : Icons.visibility_off,
                  ),
                  onPressed: () {
                    c.isLoginPasswordVisible.value = !obscured;
                  },
                ),
              ),
              onChanged: (_) => c.checkCanLogin(),
            );
          }),
          Obx(() {
            final canLogin = c.canLogin.value;
            final loading = c.isLoginLoading.value;

            return ElevatedButton(
              onPressed: canLogin && !loading ? onLogin : null,
              child: Text(loading ? '登录中…' : '登录'),
            );
          }),
        ],
      ),
    );
  }
}

复选框等其他响应式控件也可以分别使用 Obx。这样,按钮状态变化就不会再通过原来的外层观察器重建两个输入框。

切换密码可见性时,密码字段仍会重建,以更新 obscureText。拆分后,密码字段仍会与平台输入系统交互,实际效果需要测试确认。

验证与注意事项

优先在运行相同 iPadOS 版本的 iPad 真机上,使用与审核提交一致的构建进行测试。当前两组结果在设备、系统版本和运行环境上都有差异,还不能把问题限定为 M3 硬件或 iPadOS 26。

测试应覆盖手动输入、粘贴、系统密码自动填充,以及输入后切换密码可见性。还要检查横竖屏、分屏和外接键盘是否影响表现。每轮尽量只改变一个因素;如果同时拆分 Obx、替换组件并修改自动填充配置,就难以判断是哪项调整起了作用。

还需注意:

  • isLoginPasswordVisible 的命名与当前用法相反。obscureText: true 表示隐藏密码,建议后续改名为 isPasswordObscured,或统一取反逻辑。
  • 邮箱作为登录账号时,可以单独测试 AutofillHints.username。iOS 只采用 hints 列表中的第一项,不能假定多个 hint 会同时生效,详见 autofillHints 文档。
  • 如果只通过 onChanged 校验按钮状态,还要考虑程序赋值的情况。程序修改 controller 不会触发 TextField.onChanged;如果业务需要覆盖这类更新,可以在 controller 生命周期中注册监听,调用 checkCanLogin(),详见 onChanged 文档。
  • 不要在每次重建时恢复文本、反复请求焦点,或随意延迟执行来掩盖现象。这些操作可能覆盖合法输入,并干扰选区和输入法组合状态。

如果使用标准 TextField,并保持 controller 和组件结构稳定后,仍能在目标真机上复现问题,再整理最小示例、设备系统版本、构建信息及脱敏日志,提交 Flutter issue。缩小 Obx 的范围后,即使现象消失,也应将其记录为经过验证的规避措施;根因仍需通过日志或最小复现确认。

备注:内容仅供参考。