Files
aerologic-app/documents/5.5寸手持端适配技术方案.md
YANG JIANKUAN db062c8ee3 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>
2026-07-29 14:18:29 +08:00

313 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 5.5 寸手持端适配技术方案
> 适用范围AirLogistics航空物流信息管理系统在现有 10 寸平板横屏基础上,增加 5.5 寸手机竖屏形态。
> 状态:核心技术已打通并双端实机验证,参考页面「国际出港移库」已完成。
> 最后更新2026-07-28
---
## 一、背景与结论
设计稿位于 `documents/5.5寸手持端设计文件/`,共 11 个业务入口、约 66 个界面37 主页面 + 17 弹层/筛选面板 + 12 详情页 Tab 子页)。手机版是全新 UI但**功能与交互保持不变**。
前置调研结论:**本项目的逻辑与 UI 分离度很好,适配成本可集中在 XML 布局重写。**
| 层 | 现状 | 手机版可复用性 |
|---|---|---|
| ViewModel业务逻辑/网络/校验/统计) | 继承 `BaseViewModel`/`BasePageViewModel`,只持有 `MutableLiveData`,不引用 View | 完全复用 |
| Activity | 极薄。如 `IntExpStorageUseActivity` 195 行仅引用 `binding.srl`/`binding.rv`/`binding.viewModel` | 完全复用 |
| ViewHolder | 通过 id 访问控件(`binding.ivIcon` 等) | 保留同名 id 即复用 |
| Adapter | `CommonAdapter``itemLayoutId` 驱动 | 完全复用 |
| 布局 XML | Pad 横屏专用,三列表单、宽表格式 item | 需全部重写 |
| 公共 UI 组件 | `PadSearchLayout` / `PadDataLayoutNew` / `title_tool_bar` 均为 Pad 形态 | 需新增手机版 |
两个必须先解决的工程前提:
1. `app/src/main/AndroidManifest.xml`**95 个 Activity 全部为 `screenOrientation="userLandscape"`**(强制横屏)
—— 现已统一改为 `unspecified`,见下文「屏幕方向」一节
2. 项目**没有任何资源限定符目录**,只有 `res/layout/`
---
## 二、核心机制:布局按资源限定符自动切换
```
res/layout-sw600dp/activity_xxx.xml ← 平板(最小宽度 >= 600dp10 寸约 800dp
res/layout/activity_xxx.xml ← 手机兜底5.5 寸约 360~411dp
```
两个变体**同名**DataBinding 会生成同一个 Binding 基类 + 两个变体实现:
```
ActivityIntExpMoveBinding.java 公共基类Activity 中引用的就是它)
ActivityIntExpMoveBindingImpl.java 手机变体
ActivityIntExpMoveBindingSw600dpImpl.java 平板变体
```
因此 **Activity / ViewModel / ViewHolder 完全不需要设备判断**`layoutId()``itemLayoutId` 保持原样。
### 为什么不用「不同文件名 + 代码判断」
这是本次实施中被否掉的第一版方案,务必不要走回去:
```kotlin
// ❌ 错误做法
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` | 只在一个变体存在的 id基类中为可空字段 | Kotlin 强制 null 检查 |
实测生成的基类字段可空性(出港移库):
```java
@NonNull public final TextView btnMove; // 两变体都有
@NonNull public final ImageView checkIcon; // 两变体都有
@NonNull public final RecyclerView rv; // 两变体都有
@NonNull public final SmartRefreshLayout srl; // 两变体都有
@Nullable public final EditText etWaybillNo; // 仅手机变体
@Nullable public final PadSearchLayout searchWaybillNo; // 仅平板变体
```
即:**约束由编译器静态保障**,不依赖人工检查。
---
## 三、已实现的基础设施
### 1. `DeviceUtil`(新增)
`module_base/src/main/java/com/lukouguoji/module_base/util/DeviceUtil.kt`
`smallestScreenWidthDp >= 600` 区分平板/手机。关键点:读取 `Resources.getSystem()`(系统资源)而非应用 Resources以绕开 AndroidAutoSize 对 density 的动态改写,保证判定稳定。
```kotlin
DeviceUtil.isPhone() // 是否手机
DeviceUtil.isTablet() // 是否平板
DeviceUtil.describe() // "PHONE(sw=411dp, force=AUTO)",日志排查用
DeviceUtil.forceMode = DeviceUtil.ForceMode.PHONE // 调试:平板上强制走手机布局
```
`DeviceUtil.layout(padId, phoneId)` 仅用于极少数确实需要代码级判断的场景(如非 DataBinding 布局),**常规页面一律用资源限定符**。
### 2. 屏幕方向Manifest 写 `unspecified` + `BaseActivity` 按形态锁定
方案演进过了两轮,两次失败都有明确的实测证据,记录如下以免重蹈。
**方案 A一期已废弃**Manifest 保持 `userLandscape`,靠 `BaseActivity``onCreate` 里纠正。
缺陷:手机上「先横屏、约 1 秒后转竖屏」+ Activity 重建。因为 `screenOrientation` 由系统在
**创建 Activity 窗口时**生效,那时业务代码还没跑,代码只能事后纠正。
**方案 B二期已废弃**Manifest 改 `@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/` 的值,证明限定符被忽略。
**方案 C现行**Manifest 一律写 `unspecified`,系统按设备**当前物理方向**建窗口
(平板横屏摆放 → 横屏起;手机 → 竖屏起,两端起始方向本就正确),
`BaseActivity.applyDeviceOrientation()` 再按形态锁定,因方向一致故不产生二次纠正与重建。
```xml
<activity android:name="…"
android:configChanges="orientation|keyboardHidden"
android:screenOrientation="unspecified" />
```
全项目 192 处已统一(`module_p` 等 24 处本就是 `portrait` 的 PDA 页面保持不变)。
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
applyDeviceOrientation() // 必须在 super 之前
super.onCreate(savedInstanceState)
...
}
protected open fun applyDeviceOrientation() {
if (!lockOrientationByDevice()) return
requestedOrientation = if (DeviceUtil.isPhone(this))
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
}
protected open fun lockOrientationByDevice(): Boolean = true // 子类可覆写跳过
```
**已知边界**:若平板在启动瞬间被竖持且系统自动旋转开启,仍会先竖后横。
现场平板为横屏固定安装,实测无此现象。
**验证**:手机冷启动连拍 6 帧全为竖屏;平板冷启动连拍 6 帧全为横屏1280×800
登录后多次切换菜单与进出页面UI 尺寸正常无放大。
### 3. AutoSize 设计基准(易漏,后果严重)
`MyApplication.initAutoSizeConfig()` 原先只按方向切换,现增加手机分支:
| 场景 | 设计基准 |
|---|---|
| 横屏(平板) | 1152 × 720 dp |
| 竖屏 + 手机 | **390 × 844 dp** ← 本次新增 |
| 竖屏 + 平板 | 720 × 1280 dp |
| PictureSelector | 360 × 480 dp |
**若手机沿用平板竖屏的 720dp 基准,手机上所有 dp 会被压缩约一半,布局全部错位。**
### 4. 手机端公共资源
| 资源 | 内容 |
|---|---|
| `colors.xml` | `phone_primary`(#2563EB)、`phone_primary_light``phone_bg``phone_text_title/body/hint``phone_divider``phone_tag_*` |
| `bg_phone_card.xml` | 列表卡片:白底 + 12dp 圆角 + 0.5dp 边框 |
| `bg_phone_search.xml` | 搜索框:白底 + 12dp 圆角 + 1dp 边框 |
| `bg_phone_btn_primary.xml` | 主按钮:主色实底 + 10dp 圆角 + 按下态 |
| `bg_phone_tag_gray/green.xml` | 状态标签底色 |
| `layout/title_tool_bar.xml` | 手机版标题栏(平板版已移至 `layout-sw600dp/`),保留 `toolbar`/`tool_back`/`title_name` id`setBackArrow()` 零改动复用 |
---
## 四、手机版 UI 规范
| 部位 | 规范 |
|---|---|
| 画布基准 | 390 × 844 dp |
| 顶栏 | 高 48dp`phone_primary`,左「‹ 返回」,标题居中 18sp 加粗 |
| 页面背景 | `phone_bg` (#F3F4F6) |
| 列表卡片 | `bg_phone_card``marginHorizontal=16dp``marginTop=12dp``padding=14dp` |
| 卡片头行 | 圆形勾选框 20dp`radiobtn_checked_style`/`radiobtn_unchecked_style`+ 主字段 15sp 加粗 + 状态标签 |
| 卡片字段 | 两列网格13sp`phone_text_body`,行距 8dp |
| 状态标签 | 绿底=已完成 / 灰底=未完成11sp高 20dp |
| 搜索行 | 搜索框高 40dp放大镜 + 输入 + 扫码)+ 右侧筛选按钮 40dp |
| 状态 Tab | 高 42dp选中文字 `phone_primary` + 底部 28×3dp 指示条 |
| 底部操作条 | 白底 + `elevation=8dp`,全选 + 统计13sp/11sp 两行)+ 主按钮 |
| 筛选弹层 | 遮罩 `#80000000` + 底部白面板,重置 / 确定各占一半 |
---
## 五、页面改造步骤
1. **移动平板布局**
```bash
mkdir -p module_xxx/src/main/res/layout-sw600dp
git mv module_xxx/src/main/res/layout/activity_xxx.xml module_xxx/src/main/res/layout-sw600dp/activity_xxx.xml
git mv module_xxx/src/main/res/layout/item_xxx.xml module_xxx/src/main/res/layout-sw600dp/item_xxx.xml
```
2. **新建手机布局**:在 `res/layout/` 下创建**同名**文件,保持相同 `<variable>` 与共用控件 id
3. **Kotlin 侧不动**`layoutId()`、`itemLayoutId`、ViewHolder 全部保持原样
4. **纯 UI 交互写在 Activity**,不污染 ViewModel状态过滤优先复用 ViewModel 既有字段
5. 手机布局顶部 `<include layout="@layout/title_tool_bar" />` 不变(变体自动切换)
### 示例:状态 Tab 零 ViewModel 改动
出港移库的 `moveState` 字段(`""` 全部 / `"0"` 未移库 / `"1"` 已移库与查询参数本就存在Tab 直接复用:
```kotlin
// IntExpMoveActivity 中新增纯 UI 方法
fun switchTab(state: String) {
if (viewModel.moveState.value == state) return
viewModel.moveState.value = state
viewModel.searchClick() // 复用原有查询链路
}
```
```xml
<!-- 判等把字面量放前面,避免 LiveData 为 null 时崩溃 -->
android:textColor="@{`0`.equals(viewModel.moveState) ? @color/phone_primary : @color/phone_text_body}"
```
---
## 六、参考页面实施结果:国际出港移库
| 文件 | 改动 |
|---|---|
| `IntExpMoveViewModel.kt` | **0 行业务改动**(仅补注释) |
| `IntExpMoveViewHolder.kt` | **完全未改** |
| `IntExpMoveActivity.kt` | 仅新增 4 个纯 UI 方法:`switchTab()` / `toggleFilterPanel()` / `resetFilter()` / `confirmFilter()`,以及 `filterPanelVisible` 状态 |
| `layout-sw600dp/activity_int_exp_move.xml` | 原平板布局,内容未变,仅移动目录 |
| `layout-sw600dp/item_int_exp_move.xml` | 同上 |
| `layout/activity_int_exp_move.xml` | 新增手机版 |
| `layout/item_int_exp_move.xml` | 新增手机版 |
---
## 七、双端验证
| | 手机模拟器 | 平板模拟器 |
|---|---|---|
| AVD | `Medium_Phone_API_36.1` | `Aerologic_Tablet` |
| 屏幕 | 1080×2400 @420dpi | 1280×800 @160dpi |
| 形态识别 | `PHONE(sw=411dp, force=AUTO)` | `TABLET(sw=800dp, force=AUTO)` |
| 方向 | 竖屏(`unspecified` + BaseActivity 锁定,无闪屏) | 横屏 |
| 加载布局 | 手机版 | 原平板布局,**行为无变化** |
| 交互 | Tab 切换选中态迁移并触发查询;筛选弹层正常弹出/关闭 | — |
APK 内两套变体均正确打包:
```
res/layout-sw600dp-v13/activity_int_exp_move.xml
res/layout-sw600dp-v13/item_int_exp_move.xml
res/layout-sw600dp-v13/title_tool_bar.xml
res/layout/activity_int_exp_move.xml
res/layout/item_int_exp_move.xml
res/layout/title_tool_bar.xml
```
### 验证命令
```bash
~/Library/Android/sdk/emulator/emulator -list-avds
~/Library/Android/sdk/emulator/emulator -avd Medium_Phone_API_36.1 -no-snapshot-load -no-boot-anim &
adb -s <设备> install -r -t app/build/outputs/apk/debug/app-debug.apk
adb -s <设备> shell wm size && adb -s <设备> shell wm density
adb -s <设备> logcat -d -s BaseActivity:D # 查看形态识别结果
adb -s <设备> exec-out screencap -p > /tmp/shot.png
```
### 调试便利
`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
```
新增调试页时在该文件追加对应 `<activity>` 即可。
---
## 八、待办
| 项 | 说明 |
|---|---|
| `PhoneDataLayout` 手机版表单组件 | 对标 `PadDataLayoutNew`319 行 kt+ `DataLayoutKtx`404 行)+ `AutoQueryManager`179 行),支持 INPUT/SPINNER/DATE + required + AutoQuery |
| `PhoneSearchBar` 手机版搜索组件 | 当前手机版搜索框为原生 `EditText`,组件化后恢复运单号 AutoQuery 联想能力 |
| 筛选弹层内控件替换 | 当前仍复用 `PadSearchLayout`(功能完整、视觉待统一) |
| 首页 / 登录 / 我的 | **设计稿缺失**,是手机端使用闭环的前置条件 |
---
## 九、注意事项
1. 改动 `BaseActivity` / `BaseBindingActivity` / 公共组件后,**必须在平板上回归**,确认现有平板版行为不变
2. 未适配手机的页面,布局保留在 `res/layout/` 即可(两端共用),手机上视觉不佳但不会崩溃——支持渐进式适配
3. 手机版资源统一使用 `phone_*` 颜色与 `bg_phone_*` drawable禁止直接写死色值
4. 两个 git 仓库(`aerologic-app` 与 `air-cargo`)为同一份源码的独立副本,适配改动需同步