- 主列表新增待运抵/已运抵 Tab(客户端按申报状态拆分)+ 卡片长按浮层(补充/重置/回执) - 新增分单明细页:复用主列表已加载数据,零新增请求 - 新增回执内容只读页 - 新增补充信息页:参照 Pad 端收发货人信息批量应用模式(后端接口暂缺,已登记问题清单) - 手机首页「国际」Tab 增加「出港运抵」入口 - 修复两个平台坑:RecyclerView 内 GONE 不塌陷高度;Serializable Intent 传递 遇 @Transient 字段反序列化为 null 导致的崩溃 - 登记后端接口缺口 2 项(#19~#20)至问题清单 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
11 KiB
Markdown
248 lines
11 KiB
Markdown
# 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`。
|
||
|
||
## 业务层零破坏的三个模式
|
||
|
||
### 新增查询维度:默认空值 + 仅非空进请求
|
||
|
||
平板布局没有对应控件 → 字段恒为空 → 不进请求体 → 平板查询与改造前逐字节一致。
|
||
|
||
```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`)。
|
||
|
||
遇到崩溃先做这个对照实验再下结论:拿一个你没碰过的页面用同样方式打开,看是否同样崩。
|