# 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 变量必须完全一致 | 两变体 `` 中 `` 名称与类型相同 | 编译失败 | | 共用控件 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 ``` 全项目 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/` 下创建**同名**文件,保持相同 `` 与共用控件 id 3. **Kotlin 侧不动**:`layoutId()`、`itemLayoutId`、ViewHolder 全部保持原样 4. **纯 UI 交互写在 Activity**,不污染 ViewModel;状态过滤优先复用 ViewModel 既有字段 5. 手机布局顶部 `` 不变(变体自动切换) ### 示例:状态 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 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 ``` 新增调试页时在该文件追加对应 `` 即可。 --- ## 八、待办 | 项 | 说明 | |---|---| | `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`)为同一份源码的独立副本,适配改动需同步