# Pad 端零破坏 & 易崩点

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

## 布局变体的三条硬约束

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

### 1. `<variable>` 必须完全一致

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

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

### 2. 共用 id 双方都要保留

Activity / ViewHolder 引用到的 id（`srl`、`rv`、`iv_icon`、`checkIcon`…）两个变体都要有。
可以换位置换样式，但 id 得在。

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

只在手机变体存在的 id（如「查看详情」的 `ll_detail`），Binding 基类里是可空字段，
Kotlin 侧必须判空：

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

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

```bash
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 mv` 到 `layout-sw600dp/`，`layout/` 下写同名手机版（设计稿通常是底部弹层：
   `bg_phone_panel_top` + 居中标题 + `ic_phone_close` + 全宽 `bg_phone_btn_primary` 确定键）
2. 弹出位置在构造参数按形态切换（XPopup 位置与布局是两回事，都要改）：

```kotlin
class XxxDialogModel(...) : BaseDialogModel<DialogXxxBinding>(
    if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER
)
```

3. 两变体 `<variable>` 一致（imports 不要求一致）

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

## 业务层零破坏的三个模式

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

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

```kotlin
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 在手机形态下开启：

```kotlin
// 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)   // 平板不触发
}
```

```kotlin
// Activity
if (DeviceUtil.isPhone()) viewModel.initPhoneExtras()
```

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

### 纯 UI 状态放 Activity

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

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

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

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

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

```kotlin
binding.btnMove?.setOnClickListener { showMoveConfirmDialog() }
```

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

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

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

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

### 共享方法别为手机改语义

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

## 屏幕方向：Manifest 一律写 `unspecified`

新增 Activity 注册时：

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

**两种错误方案都实测踩过，别再试：**
- 写死 `userLandscape` → 手机「先横屏、约 1 秒后转竖屏」（方向在建窗口时就定了，
  `BaseActivity` 在 `super.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/分类过滤

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

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

```kotlin
// 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`/`GjcHaWb` 的 `checked`/`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 补成全新实例：

```kotlin
@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`）。

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