feat: 完成国际出港移库/出库交接页面5.5寸手机端适配

- 落地手机端双形态适配框架:同名布局+资源限定符(layout/手机、
  layout-sw600dp/平板)自动切换,DeviceUtil 设备形态判定,7个
  手机版公共组件(PhoneSearchBar/StatusTab/DataLayout/FilterPanel/
  BottomBar/StatBox/KvItem)
- 完成「国际出港移库」「国际出港出库交接」两页手机端适配,新增
  各自详情页(IntExpMoveDetailActivity/IntExpOutHandoverDetailActivity)
- 手机端首页菜单接入"出港移库""出库交接"入口
- 修复手机端进入页面先横屏后转竖屏的闪屏问题:Manifest 全项目
  192 处改为 screenOrientation="unspecified",由 BaseActivity
  按设备形态运行时锁定方向
- 修复该方案的中间版本在平板端引入的回归(先竖后横 + UI放大1.6倍)
- AutoSize 补充手机竖屏 390×844 设计基准
- 修复 CHANGELOG.md 因脚本异常导致的内容重复损坏(膨胀至12万行),
  恢复正常结构并补充本次变更记录

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 14:18:29 +08:00
parent 9c4588e47e
commit db062c8ee3
134 changed files with 19602 additions and 2965 deletions

307
CLAUDE.md
View File

@@ -839,6 +839,281 @@ adb logcat | grep "com.lukouguoji.aerologic" # 日志
---
## 5.5 寸手持端(手机版)双形态适配规范
项目需同时支持 **10 寸平板横屏****5.5 寸手机竖屏**。核心原则:**业务逻辑层完全复用,只重写布局**。
### 核心机制:布局按资源限定符自动切换
```
res/layout-sw600dp/activity_xxx.xml ← 平板(最小宽度 >= 600dp
res/layout/activity_xxx.xml ← 手机兜底5.5 寸约 360~411dp
```
两个变体**同名**DataBinding 会生成同一个 Binding 基类 + 两个变体实现:
```
ActivityXxxBinding.java 公共基类Activity 中引用的就是它)
ActivityXxxBindingImpl.java 手机变体
ActivityXxxBindingSw600dpImpl.java 平板变体
```
因此 **Activity / ViewModel / ViewHolder 的 `layoutId()`、`itemLayoutId` 全部保持原样,无需任何设备判断**
### ⛔ 严禁:用「不同文件名 + 代码判断」切换布局
```kotlin
// ❌ 错误:会生成两个不同的 Binding 类
override fun layoutId() =
if (DeviceUtil.isPhone()) R.layout.activity_xxx_phone else R.layout.activity_xxx
```
`activity_xxx_phone.xml` 生成的是 `ActivityXxxPhoneBinding`,而 `binding` 字段声明的类型是 `ActivityXxxBinding`
泛型擦除导致**编译期不报错**,但一进入该页面就 `ClassCastException` 崩溃。**必须用同名文件 + 资源限定符。**
### 布局变体的强制约束
| 约束 | 说明 |
|------|------|
| **DataBinding 变量必须完全一致** | 两个变体 `<data>` 中的 `<variable>` 名称与类型必须相同,否则编译失败 |
| **共用控件 id 必须双方都有** | Activity / ViewHolder 中引用的 id`srl`/`rv`/`checkIcon`/`iv_icon`)两个变体都要保留 |
| **单侧独有的 id 自动变 `@Nullable`** | 只在一个变体存在的 idBinding 基类中会是可空字段Kotlin 侧强制 null 检查(编译器自动保障,不用担心漏改) |
### 已改造的基础设施
| 文件 | 作用 |
|------|------|
| `module_base/.../util/DeviceUtil.kt` | 设备形态判定。按 `smallestScreenWidthDp >= 600` 区分平板/手机;读 `Resources.getSystem()` 以绕开 AutoSize 对 density 的改写,保证判定稳定 |
| `module_base/BaseActivity.kt` | `applyDeviceOrientation()` 按形态锁方向(手机竖屏 / 平板横屏。Manifest 统一写 `unspecified` 配合它使用。子类覆写 `lockOrientationByDevice()` 返回 false 可跳过 |
| `module_base/base/BaseBindingActivity.kt` | 注释固化变体约定(`layoutId()` 不做设备分支) |
| `module_base/MyApplication.kt` | **AutoSize 增加手机竖屏基准 390×844**(见下节) |
| `module_base/res/values/colors.xml` | 手机端配色 `phone_*` 系列 |
| `module_base/res/drawable/bg_phone_*.xml` | 手机端卡片、搜索框、状态标签、主按钮背景 |
| `module_base/res/layout/title_tool_bar.xml` | 手机版公共标题栏(平板版已移至 `layout-sw600dp/` |
`DeviceUtil` 用法:
```kotlin
DeviceUtil.isPhone() // 是否手机
DeviceUtil.isTablet() // 是否平板
DeviceUtil.describe() // "PHONE(sw=411dp, force=AUTO)",日志排查用
DeviceUtil.forceMode = DeviceUtil.ForceMode.PHONE // 调试:在平板上强制走手机布局
```
> `DeviceUtil.layout(padId, phoneId)` 仅用于**极少数**确实需要代码级判断的场景(如非 DataBinding 的布局)。
> **常规页面一律用资源限定符,不要调用它。**
### ⚠️ 屏幕方向Manifest 一律写 `unspecified`,由 `BaseActivity` 按形态锁定
**症状(改造前)**:手机上打开页面(开屏页最明显)先显示横屏,约 1 秒后才转竖屏,伴随 Activity 重建。
**原因**`android:screenOrientation` 由系统在**创建 Activity 窗口时**生效,此时业务代码还没执行。
Manifest 写死 `userLandscape` 时,即使 `BaseActivity``super.onCreate` 之前
`setRequestedOrientation(PORTRAIT)`,也只能在横屏窗口已建好之后再纠正 → 必然闪一下。
**做法**Manifest 统一写 `unspecified`,让系统按设备**当前物理方向**建窗口
(平板横屏摆放 → 横屏起,手机 → 竖屏起,两端起始方向本就正确),
`BaseActivity.applyDeviceOrientation()` 再按形态锁定,不产生二次纠正:
```xml
<activity android:name="…"
android:configChanges="orientation|keyboardHidden"
android:screenOrientation="unspecified" />
```
全项目 192 处已统一(`module_p` 等 24 处本就是 `portrait` 的 PDA 页面保持不变)。
#### ⛔ 别用 `@integer/screen_orientation` 这类资源引用(已验证行不通)
一度改成 `android:screenOrientation="@integer/screen_orientation"` + `values/`=portrait、
`values-sw600dp/`=userLandscape**看似优雅,实测是错的**
系统解析 Manifest 属性时**用的是默认配置,不套用设备限定符**,所以 `values-sw600dp/` 永远不会被选中,
所有 Activity 实际都拿到了 `values/` 的竖屏值。后果是平板端:
1. 先竖屏、随后被 `BaseActivity` 纠正成横屏 —— 反而把闪屏问题搬到了平板上
2. **AutoSize 在竖屏配置下选了平板竖屏基准 720dp**density=1280/720≈1.78,正常应为 1280/1152≈1.11
→ 交互几次后整个 UI 放大约 1.6 倍
> 验证方法(可复现):把 `values/` 与 `values-sw600dp/` 的值**互换**,并临时让
> `lockOrientationByDevice()` 返回 false排除运行时纠正装到平板上看首屏方向。
> 结果是平板取了 `values/` 的值 → 证明限定符被忽略。
### ⚠️ AutoSize 设计基准必须区分手机(易漏且后果严重)
项目使用 AndroidAutoSize`MyApplication.initAutoSizeConfig()` 按方向+设备切换设计尺寸:
| 场景 | 设计基准 |
|------|----------|
| 横屏(平板) | 1152 × 720 dp |
| 竖屏 + 手机 | **390 × 844 dp**(对应 5.5 寸设计稿画布) |
| 竖屏 + 平板 | 720 × 1280 dp |
| PictureSelector | 360 × 480 dp |
**若手机沿用平板竖屏的 720dp 基准,手机上所有 dp 会被压缩约一半,布局全部错位。**
### 手机版 UI 规范(依据 `documents/5.5寸手持端设计文件/` 设计稿)
| 部位 | 规范 |
|------|------|
| 画布基准 | 390 × 844 dp |
| 顶栏 | 高 48dp`@color/phone_primary`(#2563EB),左侧「‹ 返回」,标题居中 18sp 加粗 |
| 页面背景 | `@color/phone_bg` (#F3F4F6) |
| 列表卡片 | `@drawable/bg_phone_card`,圆角 12dp`marginHorizontal=16dp``marginTop=12dp``padding=14dp` |
| 卡片头行 | 圆形勾选框 20dp`radiobtn_checked_style`/`radiobtn_unchecked_style`+ 运单号 15sp 加粗 + 右侧状态标签 |
| 卡片字段 | 两列网格13sp`@color/phone_text_body`,行距 8dp |
| 状态标签 | `bg_phone_tag_green`(已完成)/ `bg_phone_tag_gray`未完成11sp |
| 搜索行 | 搜索框 40dp 高 + `bg_phone_search`,内含放大镜 + 输入 + 扫码;右侧筛选按钮 40dp |
| 状态 Tab | 高 42dp选中文字 `phone_primary` + 底部 28×3dp 指示条 |
| 底部操作条 | 白底 + `elevation=8dp`,全选 + 统计13sp/11sp 两行)+ 主按钮 `bg_phone_btn_primary` |
| 筛选弹层 | `FrameLayout` 遮罩 `#80000000` + 底部白色面板,重置 / 确定各占一半 |
### 手机版页面改造步骤
1. **移动平板布局**`git mv res/layout/activity_xxx.xml res/layout-sw600dp/activity_xxx.xml`item 布局同理)
2. **新建手机布局**:在 `res/layout/` 下创建**同名**文件,保持相同 `<variable>` 与共用控件 id
3. **Kotlin 侧不动**`layoutId()``itemLayoutId`、ViewHolder 全部保持原样
4. **纯 UI 交互**Tab 切换、弹层显隐)写在 Activity不污染 ViewModel状态过滤优先复用 ViewModel 既有字段
5. 手机布局顶部 `<include layout="@layout/title_tool_bar" />` 不变(变体自动切换)
6. **回到手机端首页加菜单入口**(最易遗忘,见下节)——不加的话页面在手机上根本进不去
### 手机端首页菜单入口(适配完必须加,否则没入口)
手机端首页是 `module_p``PDAEnterActivity`(手持机首页),底部四个 Tab 对应:
| Tab | ViewModel | 位置 |
|-----|-----------|------|
| 国内 | `GnViewModel` | `module_p/.../ui/enter/gn/` |
| 国际 | `GjViewModel` | `module_p/.../ui/enter/gj/` |
**菜单项是手写清单,不是接口下发**。权限机制只做过滤:登录返回的权限串存在
`SharedPreferenceUtil.getString(Constant.Share.authList)`(一个逗号分隔的长字符串),
菜单项按 `authList.contains(bean.key)` 决定显不显示。
所以「适配完页面但手机首页看不到入口」的原因通常**不是没权限,而是清单里没加**。
排查顺序:先确认权限串里有对应 key再去看清单。
```bash
# 看当前登录账号实际拥有的权限串
adb -s <serial> shell "run-as com.lukouguoji.aerologic \
cat /data/data/com.lukouguoji.aerologic/shared_prefs/data.xml" | tr ',' '\n' | grep -oE "App[A-Za-z]+" | sort -u
```
加一项就是加一行 —— `ActionBean` 的第 4 个参数填 ARouter 路由即可,无需写 when 分支
`module_p` 只依赖 `module_base`,看不到 `module_gjc` 等业务模块,跨模块只能走路由):
```kotlin
ActionBean(
"出港移库",
R.drawable.gjc_yi_ku_icon, // 图标复用 Pad 端同款
Constant.AuthName.GjcYiKuListActivity, // 权限串,与 Pad 端菜单保持一致
ARouterConstants.ACTIVITY_URL_INT_EXP_MOVE // 有 route 就走通用跳转
),
```
两点注意:
- **权限串与路由都要和 Pad 端菜单对齐**:以 `app/.../HomeFragment.kt` 里该权限对应的
`ARouter.build(...)` 为准。同一业务常有新旧两个 Activity如移库的 `GjcYiKuListActivity`
`IntExpMoveActivity`Pad 菜单实际跳的才是要适配的那个
- **图标**`module_p` 看不到 `module_gjc` 的资源,把 Pad 图标 PNG 复制一份到
`module_p/src/main/res/drawable-xxhdpi/`(该模块既有 4 个图标就是这么放的,同名不同密度桶可共存)
**示例:状态 Tab 零 ViewModel 改动**`IntExpMoveActivity` 已实现)
```kotlin
// Activity 中新增纯 UI 方法,写入 ViewModel 既有的 moveState 字段("" 全部 / "0" 未移库 / "1" 已移库)
fun switchTab(state: String) {
if (viewModel.moveState.value == state) return
viewModel.moveState.value = state
viewModel.searchClick() // 复用原有查询链路
}
```
```xml
<!-- XML 中判等要把字面量放前面,避免 LiveData 为 null 时崩溃 -->
android:textColor="@{`0`.equals(viewModel.moveState) ? @color/phone_primary : @color/phone_text_body}"
```
### 手机版公共组件库(`module_base/.../ui/weight/phone/`
7 个组件覆盖设计稿中反复出现的部件,**新页面优先复用,不要重写 XML**。属性均为无命名空间写法,
DataBinding 适配器统一放在 `PhoneWidgetKtx.kt`(同名属性按控件类型解析,与平板端 Pad* 组件互不冲突)。
| 组件 | 用途 | 关键属性 |
|------|------|----------|
| `PhoneSearchBar` | 顶部搜索行:搜索框 + 扫码 + 圆形筛选按钮 | `hint``value`(双向)、`showScan``showFilter``upperCase``setOnScanClickListener``setOnFilterClickListener``setRefreshCallBack` |
| `PhoneStatusTab` | 状态 Tab 条(文字 + 角标,选中态下划线) | `tabs`(List\<KeyValue\>)、`counts`(List\<String\>)、`value`(双向)、`setOnTabChanged` |
| `PhoneDataLayout` | 表单/筛选项,`PhoneDataLayoutType` = `INPUT`/`DATE`/`SPINNER` | `title``hint``type``value`(双向)、`list``required``enable``setRefreshCallBack` |
| `PhoneFilterPanel` | 底部筛选弹层(遮罩 + 面板 + 重置/确认) | `title``panelVisible``setOnResetClick``setOnConfirmClick``setOnDismissClick` |
| `PhoneBottomBar` | 底部操作条:全选 + 已选统计 + 主按钮 | `allChecked``countText``actionText``setOnAllCheckClick``setOnActionClick` |
| `PhoneStatBox` | 卡片内 2~4 列统计框(未传 label 的列自动隐藏) | `label1..4``value1..4` |
| `PhoneKvItem` | 详情页字段项(灰标签 + 数值),`tag=true` 转橙色高亮 | `title``value``tag` |
**`PhoneFilterPanel` 自带内容插槽**:把 `PhoneDataLayout` 直接写在其标签内即可,组件会自动移入面板中部
并施加 20dp 项间距,无需关心内部层级:
```xml
<com.lukouguoji.module_base.ui.weight.phone.PhoneFilterPanel
android:layout_width="match_parent"
android:layout_height="match_parent"
panelVisible="@{activity.filterPanelVisible}"
setOnResetClick="@{(v)-> activity.resetFilter()}"
setOnConfirmClick="@{(v)-> activity.confirmFilter()}"
setOnDismissClick="@{(v)-> activity.toggleFilterPanel()}">
<com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout
android:layout_width="match_parent" android:layout_height="wrap_content"
title='@{"航班日期"}' hint='@{"请选择航班日期"}'
type="@{PhoneDataLayoutType.DATE}" value="@={viewModel.flightDate}" />
</com.lukouguoji.module_base.ui.weight.phone.PhoneFilterPanel>
```
#### ⚠️ 复合自定义 View 必须屏蔽子控件状态恢复(已踩坑)
同一页面放多个 `PhoneDataLayout`/`PhoneStatBox` 时,它们的内部子控件(`et`/`tv`/`spinner`…)**共用同一套 id**。
系统按 id 保存/恢复视图状态会跨实例串档 —— 实测表现为DATE 项的默认日期被邻近输入框恢复的空文本清空,
并通过双向绑定把 ViewModel 字段也写成了空值(筛选条件静默丢失)。
因此每个含内部 id 的手机版组件都重写了:
```kotlin
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) = dispatchFreezeSelfOnly(container)
override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) = dispatchThawSelfOnly(container)
```
**新增同类组件时必须照做**(取值由 ViewModel + DataBinding 持有,本就不需要视图级恢复)。
### 已完成 / 待办
-**已完成**:适配框架 + 手机端公共资源 + 7 个手机版公共组件
-**已完成页面**
- 「国际出港移库」`IntExpMoveActivity`(框架参考页,筛选弹层内暂仍用 `PadSearchLayout`
- 「国际出港出库交接」`IntExpOutHandoverActivity` + 「板箱详情」`IntExpOutHandoverDetailActivity`
(公共组件首个完整落地页,双端实机验证通过)
-**待办**
- 把「移库」页筛选弹层内的 `PadSearchLayout` 换成 `PhoneDataLayout`,视觉统一
- `PhoneSearchBar` 接入 AutoQuery 联想(`AutoQueryManager` 目前只对接了 Pad 系组件)
### 调试便利:直接拉起页面
`app/src/debug/AndroidManifest.xml`**仅 Debug 生效,不进 Release 包**)通过 `tools:replace` 将适配调试页临时置为 `exported="true"`,免去每次登录再逐级点入:
```bash
adb shell am start -n com.lukouguoji.aerologic/com.lukouguoji.gjc.page.move.IntExpMoveActivity
adb shell am start -n com.lukouguoji.aerologic/com.lukouguoji.gjc.activity.IntExpOutHandoverActivity
```
新增调试页时在该文件追加对应 `<activity>` 即可。
> 模拟器通常连不上内网服务(提示「接口访问失败」),列表为空。若需验证卡片/详情的还原度,
> 可在 Activity 里临时注入几条样例 Bean`binding.rv.commonAdapter()?.refresh(list)`)截图比对,**验证后务必删除**。
**参考文件**`module_base/.../util/DeviceUtil.kt``module_gjc/res/layout/activity_int_exp_move.xml`(手机)、`module_gjc/res/layout-sw600dp/activity_int_exp_move.xml`(平板)
---
## Import 路径速查
### 基类与常用类
@@ -870,6 +1145,7 @@ adb logcat | grep "com.lukouguoji.aerologic" # 日志
| 类 | 正确路径 |
|----|----------|
| `DictUtils` | `com.lukouguoji.module_base.util.DictUtils` |
| `DeviceUtil` | `com.lukouguoji.module_base.util.DeviceUtil` |
| `MediaUtil` | `com.lukouguoji.module_base.util.MediaUtil` |
| `UploadUtil` | `com.lukouguoji.module_base.util.UploadUtil` |
| `KeyValue` | `dev.utils.app.info.KeyValue` |
@@ -896,6 +1172,12 @@ adb logcat | grep "com.lukouguoji.aerologic" # 日志
| 资源引用不存在 | drawable/color/string 缺失 | 先检查资源是否存在,不存在则创建或用已有资源 |
| `View.VISIBLE` 报错 | 未导入 | XML `<data>` 中加 `<import type="android.view.View" />` |
| `textStyle` DataBinding 报错 | 不支持 | 用固定值 `android:textStyle="bold"` |
| 手机版页面一进入就 `ClassCastException` | 用了「不同文件名 + 代码判断」切换布局,生成了两个 Binding 类 | 改用同名布局 + 资源限定符(`layout/``layout-sw600dp/`见「5.5 寸手持端双形态适配规范」 |
| 布局变体编译报 `variable not found` | 两个变体的 `<data>``<variable>` 不一致 | 手机与平板变体的变量名/类型必须完全相同 |
| 手机上元素小一半 / 布局错位 | AutoSize 仍按平板 720dp 基准换算 | 确认 `MyApplication.initAutoSizeConfig()` 中手机竖屏走 390×844 分支 |
| 手机版筛选项默认值(如日期)自动被清空 | 多个复合自定义 View 内部子控件 id 相同,系统按 id 恢复状态时跨实例串档 | 组件重写 `dispatchSaveInstanceState`/`dispatchRestoreInstanceState``dispatchFreezeSelfOnly`/`dispatchThawSelfOnly`(见「手机版公共组件库」) |
| 手机上页面先横屏再转竖屏(闪一下) | Manifest 写死 `screenOrientation="userLandscape"`,系统建窗口时已按横屏,代码纠正为时已晚 | 改成 `android:screenOrientation="unspecified"`,由 `BaseActivity` 按形态锁定(见「屏幕方向」小节) |
| 平板先竖屏再转横屏 + 交互后 UI 放大约 1.6 倍 | Manifest 用了 `@integer/screen_orientation` 资源引用;系统解析 Manifest 不套用限定符全部取到竖屏值AutoSize 随之选了平板竖屏 720dp 基准 | Manifest 改回 `unspecified`(见「屏幕方向」小节的 ⛔ 说明) |
---
@@ -961,10 +1243,16 @@ private fun loadDictLists() {
```xml
<activity android:name="com.lukouguoji.gjc.activity.XxxActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false" android:screenOrientation="userLandscape" />
android:exported="false"
android:screenOrientation="unspecified" />
```
> `screenOrientation` **必须写 `unspecified`**,不要写死 `userLandscape`、也不要用资源引用。
> 写死横屏会让手机端「先横屏、约 1 秒后转竖屏」闪一下;资源引用则会让平板端出问题
> (详见下方「屏幕方向」小节)。
3.`ARouterConstants` 注册路由(如需)
4. 标题栏统一用 `<include layout="@layout/title_tool_bar" />`Activity 中 `setBackArrow("标题")`
5. **双形态布局**:需同时支持手机的页面,平板布局放 `res/layout-sw600dp/`、手机布局放 `res/layout/`,两者**同名**且变量与共用 id 一致。详见「5.5 寸手持端双形态适配规范」
### 常见业务操作
@@ -1112,6 +1400,9 @@ Glide.with(itemView.context).load(glideUrl).into(binding.ivThumbnail)
- `ic_new_expand`(全部展开/收起36dp + padding 4dp
- **图标之间间距统一 `marginStart=16dp`**(禁止使用 8dp会导致视觉过近
- 常用资源: `bg_white_radius_8``colorPrimary``text_normal``text_gray``color_bottom_layout`
- **双形态适配:逻辑与 UI 严格分离** — 手机版只重写布局,业务逻辑一律复用现有 ViewModel / ViewHolder纯 UI 交互Tab 切换、弹层显隐)写在 Activity不下沉到 ViewModel
- **禁止用不同文件名切换布局** — 手机/平板布局必须同名 + 资源限定符,否则 `ClassCastException`详见「5.5 寸手持端双形态适配规范」)
- 手机版资源统一使用 `phone_*` 颜色与 `bg_phone_*` drawable禁止直接写死色值
### 环境配置
@@ -1125,4 +1416,16 @@ Glide.with(itemView.context).load(glideUrl).into(binding.ivThumbnail)
2. 资源引用错误 → 检查 drawable/color/string 是否存在
3. DataBinding 错误 → 检查 import、枚举值、View 类导入
4. suspend function 错误 → 在 `viewModelScope.launch` 中调用
5. 仍有问题 → `./gradlew clean` 后重新构建
5. 双形态布局问题 → 查「常见编译错误速查」中 3 条布局变体相关条目;可用 `DeviceUtil.describe()` 打日志确认当前形态
6. 仍有问题 → `./gradlew clean` 后重新构建
### 双端验证命令
```bash
~/Library/Android/sdk/emulator/emulator -list-avds # 可用Medium_Phone_API_36.1手机、Aerologic_Tablet平板
adb -s <设备> shell wm size && adb -s <设备> shell wm density # 确认屏幕参数
adb -s <设备> logcat -d -s BaseActivity:D # 查看形态识别结果 PHONE(sw=411dp) / TABLET(sw=800dp)
adb -s <设备> exec-out screencap -p > /tmp/shot.png # 截图比对
```
> 改动 `BaseActivity` / `BaseBindingActivity` / 公共组件后,**必须在平板上回归**,确认现有平板版行为不变。