Files
aerologic-app/.claude/skills/phone-adapt/references/pad-safety.md
YANG JIANKUAN 436202f15b fix: 出港运抵手机端对照设计稿review修正与重置弹框适配
- 状态重置弹框改手机底部弹层:dialog 布局同名双变体(Pad 移 layout-sw600dp),
  弹出位置按形态切 DIALOG_TYPE_BOTTOM/CENTER,为弹框适配首例
- 主列表补空态(文案按 Tab 切换)、回执页补「暂无回执内容」兜底
- 分单明细长按浮层按设计稿补「回执」按钮;底部统计加分母「已选 N / M 项」
- Tab 角标初始显示 0
- PhoneStatusTab/PhoneFilterPanel 补 dispatchSave/RestoreInstanceState 状态屏蔽(历史遗漏)
- 双端实机验证:手机端各修正点与设计稿一致且 crash=0;平板端列表与批量重置弹框回归无变化

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:21:33 +08:00

12 KiB
Raw Blame History

Pad 端零破坏 & 易崩点

平板端是在跑的生产功能,适配手机不能改变它的任何行为。以下是必须遵守的约束和踩过的坑。

布局变体的三条硬约束

同名布局 + 资源限定符(layout/ 手机 / layout-sw600dp/ 平板)会让 DataBinding 生成 一个 Binding 基类 + 两个变体实现,因此 Kotlin 侧零改动。代价是两个变体必须兼容:

1. <variable> 必须完全一致

手机变体需要 activity 变量(暴露纯 UI 方法给 XML平板变体也要补上,否则编译报 variable not found。平板不用它,加个注释说明即可:

<!-- 平板端不使用,仅为与手机变体保持变量一致 -->
<variable name="activity" type="com.lukouguoji.gjc.activity.XxxActivity" />

2. 共用 id 双方都要保留

Activity / ViewHolder 引用到的 idsrlrviv_iconcheckIcon…)两个变体都要有。 可以换位置换样式,但 id 得在。

3. 单侧独有的 id 会变 @Nullable

只在手机变体存在的 id如「查看详情」的 ll_detailBinding 基类里是可空字段, Kotlin 侧必须判空:

// 平板变体没有这个控件,为 null 时直接跳过
binding.llDetail?.setOnClickListener { XxxDetailActivity.start(itemView.context, bean) }

编译器会强制你处理,不用担心漏掉。改完可以核对生成代码确认:

grep -B3 "llDetail" module_gjc/build/generated/data_binding_base_class_source_out/debug/out/\
com/lukouguoji/gjc/databinding/ItemXxxBinding.java

绝对不要用「不同文件名 + 代码判断」切布局

activity_xxx_phone.xml 会生成 ActivityXxxPhoneBinding,而字段声明的类型是 ActivityXxxBinding。泛型擦除让编译期查不出来,一进页面就 ClassCastException

弹框BaseDialogModel也要双变体最容易漏

页面布局适配完,长按/批量操作弹出的 DialogModel 仍会以 Pad 居中样式出现在手机上 (出港运抵的「状态重置」就漏过)。做法与页面完全同构:

  1. dialog 布局 git mvlayout-sw600dp/layout/ 下写同名手机版(设计稿通常是底部弹层: bg_phone_panel_top + 居中标题 + ic_phone_close + 全宽 bg_phone_btn_primary 确定键)
  2. 弹出位置在构造参数按形态切换XPopup 位置与布局是两回事,都要改):
class XxxDialogModel(...) : BaseDialogModel<DialogXxxBinding>(
    if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER
)
  1. 两变体 <variable> 一致imports 不要求一致)

参考:IntExpArriveResetDialogModel + dialog_int_exp_arrive_reset.xml 双变体。 适配收尾时逐个 grep 该页 ViewModel 里 DialogModel( 的调用点,每个弹框都过一遍。

业务层零破坏的三个模式

新增查询维度:默认空值 + 仅非空进请求

平板布局没有对应控件 → 字段恒为空 → 不进请求体 → 平板查询与改造前逐字节一致。

val handoverState = MutableLiveData("")     // 手机 Tab 绑这个,平板无控件
val hoId = MutableLiveData("")

val filterParams = mapOf(
    "fdate" to flightDate.value?.ifEmpty { null },
    /* 原有字段… */
    "hoState" to handoverState.value?.ifEmpty { null },   // 空 → null → 不影响平板
    "hoId" to hoId.value?.ifEmpty { null },
)

手机专属的额外请求ViewModel 内部开关

Tab 角标计数、新增下拉字典这类请求,平板不需要,多发就是改变了平板行为(请求次数)。 用一个内部布尔控制,由 Activity 在手机形态下开启:

// ViewModel
private var phoneExtrasEnabled = false

fun initPhoneExtras() {
    phoneExtrasEnabled = true
    handoverState.value = "0"                       // 手机默认停在第一个 Tab
    DictUtils.getWHSUserList(addAll = false) { hoUserList.postValue(it) }
}

override fun getData() {
    /* 原有列表 + 统计请求… */
    if (phoneExtrasEnabled) loadTabCounts(filterParams)   // 平板不触发
}
// Activity
if (DeviceUtil.isPhone()) viewModel.initPhoneExtras()

DeviceUtil.isPhone() 只用来决定数据默认值和额外请求永远不用来选布局

纯 UI 状态放 Activity

弹层显隐、Tab 高亮这类与业务无关的状态,用 ObservableBoolean 放在 Activity 上暴露给 XML 不要污染 ViewModel

val filterPanelVisible = ObservableBoolean(false)
fun toggleFilterPanel() = filterPanelVisible.set(!filterPanelVisible.get())

同名 id 在两个变体里必须是同一种控件

平板变体的 btnMoveTextView,手机版底部换成了 PhoneBottomBar——不要为了复用 Activity 里的 binding.btnMove 而把这个 id 安到 PhoneBottomBar 上。同名不同类型会让 DataBinding 把字段类型退化成公共父类,编译期未必报错,运行时行为难料。

正确做法:手机端的按钮点击走组件自身的 setOnActionClickbtnMove 就只留在平板变体里, Activity 侧判空调用:

binding.btnMove?.setOnClickListener { showMoveConfirmDialog() }

被 XML 调用的 Activity 方法记得改成 public。

调字典要对齐既有页面的参数口径

DictUtils 的方法参数常有"看起来合理但后端不认"的取值。出港移库的特码下拉一开始传 ieFlag="E"(出港),接口返回空数组;而同模块既有页面(GjcWeighingStartViewModel 等) 一律传 ieFlag="",能正常返回。

所以调字典前先 grep 一下同模块其他页面怎么调的,照抄它们的参数;改完必须在实机上 把下拉点开确认真的有数据,空列表在 UI 上看不出报错。

共享方法别为手机改语义

checkAllClick() 这类平板也在用的方法,不要为了手机端的语义去改它。 例手机「待交接」Tab 下理论上只有未交接记录(服务端按状态过滤),所以全选照原样全量勾选就是对的; 若为此加上「跳过已交接」的过滤,反而改变了平板端(平板列表混排两种状态)的行为。

屏幕方向Manifest 一律写 unspecified

新增 Activity 注册时:

<activity android:name="…"
    android:configChanges="orientation|keyboardHidden"
    android:exported="false"
    android:screenOrientation="unspecified" />

两种错误方案都实测踩过,别再试:

  • 写死 userLandscape → 手机「先横屏、约 1 秒后转竖屏」(方向在建窗口时就定了, BaseActivitysuper.onCreate 之前纠正也来不及)
  • @integer/screen_orientation 资源引用(曾经以为是优雅方案)→ 更糟:系统解析 Manifest 属性不套用设备限定符,所有 Activity 实际都拿到 values/ 默认值(竖屏), 平板因此先竖后横,且 AutoSize 在竖屏配置下选中平板竖屏 720dp 基准 (正常应为 1152×720 横屏基准),交互几次后整个 UI 放大约 1.6 倍

unspecified 让系统按设备当前物理方向建窗口(平板横屏摆放→横屏起,手机→竖屏起, 两端起始方向本就正确),BaseActivity.applyDeviceOrientation() 再按形态锁定, 不产生二次纠正。全项目 192 处已统一为 unspecified

改 module_base 公共代码时

module_base 被所有业务模块依赖,改动会影响全部页面。原则:

  • 只做增量:加新组件、新颜色、新 drawable别改既有组件的行为
  • 确实要改公共基类(BaseActivity / BaseBindingActivity / Pad 系组件)时, 必须在平板上回归至少 2~3 个不同类型的既有页面
  • Bean 上加只读计算属性是安全的Gson 只序列化字段,不动 getter字段要谨慎,会进请求体

RecyclerView 里不要用 item 级 visibility 做 Tab/分类过滤

普通 ViewGroupLinearLayout 等)里 View.GONE 的子 View 会自动塌陷为 0 高度、不占布局空间—— 但 RecyclerView + LinearLayoutManager 不是这样:它按子项的测量高度参与滚动区域计算, 不会因为子项是 GONE 就跳过。给 item 根节点加 visibility="@{tab条件 ? VISIBLE : GONE}" 切 Tab会在列表里留下一段空白隐藏项的高度仍被保留角标数字和实际卡片数量对不上。

出港运抵的「待运抵/已运抵」Tab 就踩了这个坑(实机截图验证:切到「已运抵」后顶部有一大块 空白,隐藏的待运抵卡片高度并未塌陷)。正确做法是在数据层过滤,而不是视图层:

// ViewModel自己维护完整列表不依赖 adapter.items 的历史状态
// 见下一节adapter.items 在 loadMore 场景可能已经是上次过滤后的子集,不能当作真源)
private var allLoadedList: List<GjcMaWb> = emptyList()

override fun getData() {
    launchLoadingCollect({ ... }) {
        onSuccess = { result ->
            val newPageItems = result.list ?: emptyList()
            allLoadedList = if (pageModel.page <= 1) newPageItems else allLoadedList + newPageItems

            pageModel.handleListBean(result) {
                // 无论 handleListBean 内部把 adapter.items 搞成什么样,这里用 allLoadedList
                // 强制覆盖为「当前 Tab 的正确子集」,兜底纠正
                val filtered = allLoadedList.filter { it.isPending == (currentTab.value == "pending") }
                pageModel.rv?.commonAdapter()?.refresh(filtered)
            }
        }
    }
}

fun onTabChangedPhone() {
    val filtered = allLoadedList.filter { it.isPending == (currentTab.value == "pending") }
    pageModel.rv?.commonAdapter()?.refresh(filtered)   // 只是 items.clear()+addAll()+notifyDataSetChanged无副作用
}

CommonAdapter.refresh() 只是替换内部列表并 notifyDataSetChanged(),不影响 SmartRefreshLayout 的分页/完成状态,可以放心在 handleListBean 回调里再调用一次。

Intent 传递 Serializable Bean@Transient 字段反序列化后是 null

GjcMaWb/GjcHaWbchecked/showMore/actionMenuVisible 三个 UI 状态字段都标了 @Transient(本意是避免 Gson 把 ObservableBoolean 的内部结构序列化进网络请求体)。 但 Kotlin 的 @Transient 注解生成的就是 JVM 的 transient 修饰符,这个修饰符对 Java 对象序列化Intent.putExtra(String, Serializable) 走的正是这条路径)有实际含义: 反序列化时会跳过 transient 字段的恢复,对象引用类型的字段结果就是 null

新增手机专属页面时,如果要把 List<GjcHaWb> 通过 ArrayList<GjcHaWb> 塞进 Intent 传给下一个页面接收方拿到的对象「看起来」字段都对wbNo、pc、weight 这些正常字段没问题, 因为它们不是 @Transient),但只要调用 bean.checked.set(...) 就会立刻崩溃:

NullPointerException: Attempt to invoke virtual method
'void androidx.databinding.ObservableBoolean.set(boolean)' on a null object reference

出港运抵的「分单明细」页第一版全选按钮就是这样崩的。修复只需一行,在接收方 initOnCreated 里对拿到的列表整体 .copy() 一遍——.copy() 会重新触发一次真正的对象构造, 类体里 val checked = ObservableBoolean(false) 这类初始化逻辑跟着重新跑一遍, 把 null 补成全新实例:

@Suppress("UNCHECKED_CAST")
fun initOnCreated(intent: Intent) {
    val list = intent.getSerializableExtra(Constant.Key.DATA) as? ArrayList<GjcHaWb>
    // .copy() 补上被 Java 序列化跳过的 @Transient 字段
    fullList = (list ?: emptyList()).map { it.copy() }
}

这个坑只在「Intent 传递 Serializable Bean + 接收方要切换 checked/showMore 等状态」时才会 触发;纯只读展示(如回执页)不调用这些字段的 setter不会崩但补一道 .copy() 无害, 建议只要通过 Intent 传了 haWbList/maWbList 就统一加上,别等下次踩到才想起来。

已知的既有问题(不是你改坏的)

am start 直启页面时,若先经过首页再启目标页,会崩在 LoadingModel.showLoading → XPopup popupInfo is null。在未改动的页面上同样复现, 属于既有缺陷。冷启动直启不受影响force-stop 后直接 am start)。

遇到崩溃先做这个对照实验再下结论:拿一个你没碰过的页面用同样方式打开,看是否同样崩。