# 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   ← 平板（最小宽度 >= 600dp，10 寸约 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`）为同一份源码的独立副本，适配改动需同步
