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

14 KiB
Raw Blame History

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 CommonAdapteritemLayoutId 驱动 完全复用
布局 XML Pad 横屏专用,三列表单、宽表格式 item 需全部重写
公共 UI 组件 PadSearchLayout / PadDataLayoutNew / title_tool_bar 均为 Pad 形态 需新增手机版

两个必须先解决的工程前提:

  1. app/src/main/AndroidManifest.xml95 个 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 保持原样。

为什么不用「不同文件名 + 代码判断」

这是本次实施中被否掉的第一版方案,务必不要走回去:

// ❌ 错误做法
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 引用的 idsrl/rv/checkIcon/iv_icon 等) 见下
单侧独有 id 自动 @Nullable 只在一个变体存在的 id基类中为可空字段 Kotlin 强制 null 检查

实测生成的基类字段可空性(出港移库):

@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 的动态改写,保证判定稳定。

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,靠 BaseActivityonCreate 里纠正。 缺陷:手机上「先横屏、约 1 秒后转竖屏」+ Activity 重建。因为 screenOrientation 由系统在 创建 Activity 窗口时生效,那时业务代码还没跑,代码只能事后纠正。

方案 B二期已废弃Manifest 改 @integer/screen_orientation values/=portrait、values-sw600dp/=userLandscape。看似优雅,实测是错的

系统解析 Manifest 属性时用的是默认配置,不套用设备限定符values-sw600dp/ 永远不会被选中。 于是所有 Activity 都拿到了 values/ 的竖屏值,平板端出现两个回归:

  1. 先竖屏、再被 BaseActivity 纠正成横屏(把闪屏从手机搬到了平板)
  2. AutoSize 在竖屏配置下选了平板竖屏基准 720dpdensity = 1280/720 ≈ 1.78 正常应为 1280/1152 ≈ 1.11)→ 交互几次后整个 UI 放大约 1.6 倍

验证方法(可复现):把 values/values-sw600dp/ 的取值互换,并临时让 lockOrientationByDevice() 返回 false 以排除运行时纠正,装到平板上看首屏方向 —— 结果平板取的是 values/ 的值,证明限定符被忽略。

方案 C现行Manifest 一律写 unspecified,系统按设备当前物理方向建窗口 (平板横屏摆放 → 横屏起;手机 → 竖屏起,两端起始方向本就正确), BaseActivity.applyDeviceOrientation() 再按形态锁定,因方向一致故不产生二次纠正与重建。

<activity android:name="…"
    android:configChanges="orientation|keyboardHidden"
    android:screenOrientation="unspecified" />

全项目 192 处已统一(module_p 等 24 处本就是 portrait 的 PDA 页面保持不变)。

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_lightphone_bgphone_text_title/body/hintphone_dividerphone_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 idsetBackArrow() 零改动复用

四、手机版 UI 规范

部位 规范
画布基准 390 × 844 dp
顶栏 高 48dpphone_primary,左「‹ 返回」,标题居中 18sp 加粗
页面背景 phone_bg (#F3F4F6)
列表卡片 bg_phone_cardmarginHorizontal=16dpmarginTop=12dppadding=14dp
卡片头行 圆形勾选框 20dpradiobtn_checked_style/radiobtn_unchecked_style+ 主字段 15sp 加粗 + 状态标签
卡片字段 两列网格13spphone_text_body,行距 8dp
状态标签 绿底=已完成 / 灰底=未完成11sp高 20dp
搜索行 搜索框高 40dp放大镜 + 输入 + 扫码)+ 右侧筛选按钮 40dp
状态 Tab 高 42dp选中文字 phone_primary + 底部 28×3dp 指示条
底部操作条 白底 + elevation=8dp,全选 + 统计13sp/11sp 两行)+ 主按钮
筛选弹层 遮罩 #80000000 + 底部白面板,重置 / 确定各占一半

五、页面改造步骤

  1. 移动平板布局
    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 直接复用:

// IntExpMoveActivity 中新增纯 UI 方法
fun switchTab(state: String) {
    if (viewModel.moveState.value == state) return
    viewModel.moveState.value = state
    viewModel.searchClick()      // 复用原有查询链路
}
<!-- 判等把字面量放前面,避免 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

验证命令

~/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",免去每次登录再逐级点入:

adb shell am start -n com.lukouguoji.aerologic/com.lukouguoji.gjc.page.move.IntExpMoveActivity

新增调试页时在该文件追加对应 <activity> 即可。


八、待办

说明
PhoneDataLayout 手机版表单组件 对标 PadDataLayoutNew319 行 kt+ DataLayoutKtx404 行)+ AutoQueryManager179 行),支持 INPUT/SPINNER/DATE + required + AutoQuery
PhoneSearchBar 手机版搜索组件 当前手机版搜索框为原生 EditText,组件化后恢复运单号 AutoQuery 联想能力
筛选弹层内控件替换 当前仍复用 PadSearchLayout(功能完整、视觉待统一)
首页 / 登录 / 我的 设计稿缺失,是手机端使用闭环的前置条件

九、注意事项

  1. 改动 BaseActivity / BaseBindingActivity / 公共组件后,必须在平板上回归,确认现有平板版行为不变
  2. 未适配手机的页面,布局保留在 res/layout/ 即可(两端共用),手机上视觉不佳但不会崩溃——支持渐进式适配
  3. 手机版资源统一使用 phone_* 颜色与 bg_phone_* drawable禁止直接写死色值
  4. 两个 git 仓库(aerologic-appair-cargo)为同一份源码的独立副本,适配改动需同步