设计稿11「进港移库」经确认归属国内进港移库(DomImpMove后端8接口齐全), 此前误判为国际进港并预接了不存在的IntImpMove模块(问题清单#31作废)。 - 删除module_gjj的IntImpMove全套实现(3页面+Bean+Api+菜单+路由+权限串) - GnjMoveStashListActivity双形态适配:待移库/已移库Tab走search/searchMoved 双端点,Tab角标、筛选弹层4项、全选/批量移库复用平板既有链路 - 新增手机专属页:运单详情(Intent传bean零请求)+拍照上传(detail回填 已有照片,modify最小体三字段提交,与平板移交编辑页同链路) - 手机首页入口改挂国内Tab(GnViewModel补通用route跳转,权限串AppDomImpMove) - 内网真实数据双端验证通过;Proxyman A/B:wbNo/awbType生效, fdate/fno/spCode被后端静默忽略(#32)、opId/opDate恒null(#33) - 问题清单更正#31+新增#32~#34,CLAUDE.md/CHANGELOG/api-doc.md同步 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
75 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
项目概况
项目名称: AirLogistics - 航空物流信息管理系统 架构模式: MVVM + 组件化 | 语言: Kotlin 1.6.21 + Java | 版本: 1.8.4 (versionCode 84) SDK: minSdk 24 / targetSdk 30 / compileSdk 31 | Gradle: 7.3.3 | JDK: 1.8
核心架构
- MVVM 基类:
BaseActivity→BaseBindingActivity(DataBinding)、BaseViewModel→BasePageViewModel(分页列表) - 适配器:
CommonAdapter+BaseViewHolder统一列表封装 - 路由: ARouter 1.5.2 | 事件: FlowBus(Flow)+ EventBus 3.1.1
- 网络: Retrofit 2.6.1 + OkHttp 3.12.12 + Coroutines
launchCollect:无 Loading 后台请求launchLoadingCollect:带 Loading 请求toRequestBody:Map/Bean 转 JSON
组件化模块
| 模块 | 说明 | 模块 | 说明 |
|---|---|---|---|
app/ |
应用壳层 | module_base/ |
核心基础库 |
module_gnc/ |
国内出港 | module_gnj/ |
国内进港 |
module_gjc/ |
国际出港 | module_gjj/ |
国际进港 |
module_hangban/ |
航班管理 | module_cargo/ |
货物追踪 |
module_mit/ |
监装监卸 | module_p/ |
PDA 功能 |
Printer/ |
蓝牙打印 | MPChartLib/ |
图表库 |
关键目录结构
aerologic-app/
├── app/src/main/java/com/lukouguoji/aerologic/
│ ├── ui/viewModel/ # ViewModel
│ ├── ui/fragment/ # Fragment
│ └── page/ # 业务页面
├── module_base/src/main/java/com/lukouguoji/module_base/
│ ├── base/ # 基类 (BaseActivity, BaseViewModel, BaseViewHolder, BaseDialogModel)
│ ├── bean/ # 数据模型
│ ├── common/ # 常量 (Constant, DetailsPageType, ConstantEvent)
│ ├── http/net/ # 网络 (NetApply, Api)
│ ├── ktx/ # 扩展函数
│ ├── impl/ # FlowBus, observe
│ ├── interfaces/ # IOnItemClickListener
│ ├── router/ # ARouterConstants
│ └── ui/weight/ # UI 组件 (PadSearchLayout, PadDataLayout, PadDataLayoutNew)
├── module_gjc/src/main/ # 国际出港(典型参考模块)
└── 其他业务模块...
6 种典型页面类型
基于 module_gjc(国际出港)模块归纳,覆盖项目中所有常见页面模式。
类型 1:列表查询页
代表: GjcBoxWeighingActivity / GjcInspectionActivity
结构: 搜索条件区 + SmartRefreshLayout 分页列表 + 底部统计/操作栏
Activity 骨架:
@Route(path = ARouterConstants.ACTIVITY_URL_XXX)
class XxxActivity : BaseBindingActivity<ActivityXxxBinding, XxxViewModel>() {
override fun layoutId() = R.layout.activity_xxx
override fun viewModelClass() = XxxViewModel::class.java
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("页面标题")
binding.viewModel = viewModel
// 绑定分页
viewModel.pageModel.bindSmartRefreshLayout(binding.srl, binding.rv, viewModel, this)
// 监听刷新事件
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).observe(this) { viewModel.refresh() }
viewModel.refresh()
}
}
ViewModel 骨架:
class XxxViewModel : BasePageViewModel() {
// 搜索条件
val flightDate = MutableLiveData(DateUtils.getCurrentTime().formatDate())
val flightNo = MutableLiveData("")
// 适配器配置
val itemViewHolder = XxxViewHolder::class.java
val itemLayoutId = R.layout.item_xxx
// 统计数据
val totalCount = MutableLiveData("0")
fun searchClick() { refresh() }
override fun getData() {
val params = mapOf(
"pageNum" to pageModel.page, "pageSize" to pageModel.limit,
"fdate" to flightDate.value?.ifEmpty { null },
"fno" to flightNo.value?.ifEmpty { null }
).toRequestBody()
launchLoadingCollect({ NetApply.api.getXxxList(params) }) {
onSuccess = { pageModel.handleListBean(it) }
}
// 统计(无 Loading,不阻塞列表)
launchCollect({ NetApply.api.getXxxTotal(totalParams) }) {
onSuccess = { totalCount.value = (it.data?.count ?: 0).toString() }
}
}
}
布局结构:
<LinearLayout orientation="vertical">
<include layout="@layout/title_tool_bar" />
<!-- 搜索区:PadSearchLayout 横排 + 操作按钮(如有) -->
<LinearLayout orientation="horizontal">
<PadSearchLayout type="@{SearchLayoutType.DATE}" value="@={viewModel.flightDate}" />
<PadSearchLayout type="@{SearchLayoutType.INPUT}" value="@={viewModel.flightNo}" />
<ImageView style="@style/iv_search_action" android:onClick="@{()-> viewModel.searchClick()}" />
<!-- 如需新增/删除按钮,尺寸规范见「开发原则」工具栏图标尺寸规范 -->
</LinearLayout>
<!-- 分页列表 -->
<SmartRefreshLayout android:id="@+id/srl" layout_weight="1">
<RecyclerView android:id="@+id/rv"
itemLayoutId="@{viewModel.itemLayoutId}" viewHolder="@{viewModel.itemViewHolder}" />
</SmartRefreshLayout>
<!-- 底部统计栏 -->
<LinearLayout background="@color/color_bottom_layout" height="50dp">
<TextView text='@{"合计:" + viewModel.totalCount + "票"}' />
</LinearLayout>
</LinearLayout>
参考文件: module_gjc/.../GjcBoxWeighingActivity.kt、GjcBoxWeighingViewModel.kt
列表项布局规范 (item_xxx.xml)
整体结构: 水平 LinearLayout → 左侧图标 + 中间内容区(多行 KV)+ 右侧箭头
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="15dp"
android:layout_marginVertical="5dp"
android:background="@drawable/bg_item"
android:orientation="horizontal"
android:padding="10dp">
<!-- 左侧图标(普通列表用 img_plane,多选列表用 img_plane/img_plane_s 切换) -->
<ImageView
android:id="@+id/iv_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:src="@drawable/img_plane" />
<!-- 中间内容区 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_weight="1"
android:orientation="vertical">
<!-- 第一行 KV -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- KV 组件(见下方单个 KV 模板) -->
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- 第二行 KV(marginTop=10dp) -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<!-- KV 组件(weight 和 completeSpace 必须与第一行对应位置相同) -->
</androidx.appcompat.widget.LinearLayoutCompat>
</LinearLayout>
<!-- 右侧箭头(固定写法) -->
<ImageView
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_marginLeft="10dp"
android:src="@drawable/img_pda_right" />
</androidx.appcompat.widget.LinearLayoutCompat>
单个 KV 组件模板:
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.0"
android:gravity="center_vertical">
<TextView
completeSpace="@{5}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="标签名:" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{bean.fieldName}" />
</androidx.appcompat.widget.LinearLayoutCompat>
关键对齐规则:
| 规则 | 说明 | 示例 |
|---|---|---|
| 列 weight 一致 | 第一行和第二行相同位置的 KV 组件必须使用相同的 layout_weight |
第一行第 1 列 weight=1.0,第二行第 1 列也必须 weight=1.0 |
| 列 completeSpace 取最大值 | 同一列位置的 completeSpace 取两行中较大的那个值(1 个汉字 = 1 宽度,1 个标点 = 1 宽度) | 第一行"运单号:"=4,第二行"特码:"=3 → 两行都用 completeSpace=4 |
| 右侧箭头统一 | 固定使用 @drawable/img_pda_right,尺寸 30x30dp,layout_gravity="center",marginLeft="10dp" |
— |
completeSpace 计算方法: 统计 Label 字符数(含冒号),每个汉字算 1,每个标点(:)算 1。示例:
- "运单号:" = 3字 + 1标点 = 4
- "运单类型:" = 4字 + 1标点 = 5
- "状态:" = 2字 + 1标点 = 3
- "始发站:" = 3字 + 1标点 = 4(注:此处"始发站:"虽然有4个字符位,但":"也要占1个宽度)
同列两行的 completeSpace 统一取 max(row1, row2)。例如第 1 列:row1"运单号:"=4,row2"特码:"=3 → 两行都用 4。
常用字段 weight 参考表(基于国际出港模块统计):
| 字段类型 | 典型 weight | 常见范围 | 典型 completeSpace |
|---|---|---|---|
| 运单号 | 1.0 | 0.9~1.2 | 4 |
| 件数 | 1.2 | 0.8~1.2 | 3~5 |
| 重量 | 0.8 | 0.7~1.0 | 3~5 |
| 状态 | 0.8 | 0.7~0.8 | 3~4 |
| 代理 | 0.8 | 0.7~0.8 | 3~4 |
| 特码 | 1.0 | 0.9~1.0 | 3~4 |
| 始发站/目的站 | 0.8 | 0.7~0.8 | 4 |
| 运单类型/业务类型 | 1.2 | 1.0~1.2 | 5 |
| 分单数 | 0.8 | 0.6~0.8 | 4 |
| 航班号/航班 | 1.0~1.2 | 1.0~1.2 | 4~5 |
| 时间类(入库/离港/过磅) | 1.0~1.2 | 1.0~1.2 | 5 |
原则: 相同字段在不同页面应使用相近的 weight,优先参照同模块已有布局。若新页面与已有页面字段完全相同,应直接复用其 weight 和 completeSpace 配置。
典型 weight 分布示例(5列,运单号/状态/代理/件数/重量 + 特码/始发站/目的站/运单类型/分单数):
位置: 第1列 第2列 第3列 第4列 第5列
weight: 1.0 0.8 0.8 1.2 0.8 ← 第一行
weight: 1.0 0.8 0.8 1.2 0.8 ← 第二行(必须相同)
cSpace: 4 4 4 5 4 ← 第一行
cSpace: 4 4 4 5 4 ← 第二行(必须相同,取 max)
参考文件: module_gjc/.../item_int_exp_tally.xml(典型)、item_gjc_query.xml、item_gjc_box_weighing.xml
类型 2:多选列表 + 批量操作页
代表: IntExpOutHandoverActivity / GjcAssembleAllocateActivity
结构: 类型 1 基础上 + 全选按钮 + ObservableBoolean 选中状态 + 批量操作
与类型 1 的区别:
- Bean 增加 ObservableBoolean:
class XxxBean {
val checked: ObservableBoolean = ObservableBoolean(false)
var isSelected: Boolean
get() = checked.get()
set(value) = checked.set(value)
}
- ViewModel 增加全选逻辑:
val isAllChecked = MutableLiveData(false)
init {
isAllChecked.observeForever { checked ->
val list = pageModel.rv?.commonAdapter()?.items as? List<XxxBean> ?: return@observeForever
list.forEach { it.checked.set(checked) }
pageModel.rv?.commonAdapter()?.notifyDataSetChanged()
}
}
fun checkAllClick() {
val list = pageModel.rv?.commonAdapter()?.items as? List<XxxBean> ?: return
val shouldCheckAll = !isAllChecked.value!!
list.forEach { it.checked.set(shouldCheckAll) }
isAllChecked.value = shouldCheckAll
pageModel.rv?.commonAdapter()?.notifyDataSetChanged()
}
fun batchAction() {
val selected = (pageModel.rv?.commonAdapter()?.items as? List<XxxBean>)
?.filter { it.isSelected } ?: return
if (selected.isEmpty()) { showToast("请选择数据"); return }
launchLoadingCollect({ NetApply.api.batchXxx(selected.toRequestBody()) }) {
onSuccess = { showToast("操作成功"); refresh() }
}
}
- Activity 观察全选图标:
viewModel.isAllChecked.observe(this) { binding.checkIcon.alpha = if (it) 1.0f else 0.5f }
- Item 布局图片切换:
<ImageView android:id="@+id/iv_icon"
loadImage="@{bean.checked.get() ? @drawable/img_plane_s : @drawable/img_plane}" />
- ViewHolder 中处理点击:
binding.ivIcon.setOnClickListener {
bean.checked.set(!bean.checked.get())
binding.executePendingBindings()
}
参考文件: module_gjc/.../IntExpOutHandoverActivity.kt、IntExpOutHandoverViewModel.kt
类型 3:嵌套多选列表页
代表: IntExpStorageUseActivity
结构: 主列表含子列表(展开/收起)+ 主子联动全选 + Dialog 操作
与类型 2 的区别:
- Activity 暴露给布局(用于调用 Dialog 方法):
binding.activity = this // XML 中可调用 activity.showXxxDialog()
- 联动全选(主+子列表):
isAllChecked.observeForever { checked ->
val list = pageModel.rv?.commonAdapter()?.items as? List<GjcMaWb> ?: return@observeForever
list.forEach {
it.checked.set(checked)
it.storageUseList?.forEach { sub -> sub.checked.set(checked) }
}
}
- 全局展开/收起:
val isAllExpanded = MutableLiveData(false)
fun toggleAllExpand() {
val shouldExpand = !isAllExpanded.value!!
isAllExpanded.value = shouldExpand
(pageModel.rv?.commonAdapter()?.items as? List<GjcMaWb>)?.forEach {
if (!it.storageUseList.isNullOrEmpty()) it.showMore.set(shouldExpand)
}
pageModel.rv?.commonAdapter()?.notifyDataSetChanged()
}
- 子列表项 checkbox 样式(必须使用
_style系列,禁止使用_gray系列):
<!-- 子列表项 item_xxx_sub.xml 中的 iv_checkbox -->
<ImageView
android:id="@+id/iv_checkbox"
android:layout_width="0dp"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:layout_weight="0.5"
loadImage="@{bean.checked.get() ? @drawable/radiobtn_checked_style : @drawable/radiobtn_unchecked_style}"
android:src="@drawable/radiobtn_unchecked_style" />
| 资源 | 含义 | 外观 |
|---|---|---|
radiobtn_checked_style |
选中 | colorPrimary 蓝色实心圆 + 白色内环 |
radiobtn_unchecked_style |
未选中 | 透明 + 黑色边框圆 |
radiobtn_checked_gray |
❌ 禁用 | 灰色实心圆(错误样式) |
含子列表的列表项 UI 规范(以 item_int_exp_storage_use.xml 为基准)
主列表项卡片:
| 部位 | 属性 | 标准值 |
|---|---|---|
| 外层容器 | marginHorizontal / marginTop | 10dp / 10dp |
| 卡片背景 | background | @drawable/bg_white_radius_8 |
| 内容区 | padding | 10dp |
| 选中图标 | 尺寸 / marginEnd / marginTop | 40×40dp / 10dp / 0.5px(像素级对齐) |
| 选中图标 | 切换资源 | img_plane_s(选中)/ img_plane(未选中) |
| KV 文字 | textSize | 15sp(Key 和 Value 均需显式设置) |
| 首要字段值(运单号) | textColor | @color/colorPrimary |
| 其他字段 | textColor | 无需设置(继承默认 text_normal) |
| 两行间距 | layout_marginTop | 10dp |
展开/折叠按钮(iv_show):
| 属性 | 标准值 |
|---|---|
| layout_width | match_parent |
| layout_height | 10dp |
| layout_marginTop | -10dp(向上收紧,紧贴卡片底边) |
| scaleType | centerInside(保证箭头不被压扁/截断) |
| src | @mipmap/img_down |
| 显示控制 | visible="@{bean.subList != null && !bean.subList.empty}" |
| 不设 padding,不设 layout_marginBottom | — |
旧版本(18dp + padding=4dp + marginBottom=5dp)已统一替换为上述新标准,参考
item_int_exp_arrive.xml。组装类列表若需展开后翻转箭头,附加android:rotation="@{bean.showMore.get() ? 180f : 0f}"(参考item_int_exp_assemble.xml)。
子列表区域:
- 容器:
layout_marginTop="5dp",background="#e3f6e0" - 表头行:
layout_marginVertical="10dp",paddingHorizontal="10dp" - 表头文字:
textSize="14sp",textColor="@color/text_normal",textStyle="bold",gravity="center" - 表头下方分隔线:
MaterialDivider高度 1px,background="@color/c999999" - 子列表项 padding:
paddingHorizontal="10dp",paddingVertical="8dp" - 子列表文字:
textSize="14sp",textColor="@color/text_normal",gravity="center",layout_gravity="center_vertical"
子列表复选框(关键):
<ImageView
android:layout_width="0dp"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:layout_weight="0.5"
loadImage="@{bean.checked.get() ? @drawable/radiobtn_checked_style : @drawable/radiobtn_unchecked_style}"
android:src="@drawable/radiobtn_unchecked_style" />
loadImage和android:src均须使用_style系列,禁止使用_gray系列(参见上方复选框样式表)
参考文件: module_gjc/.../IntExpStorageUseActivity.kt、IntExpStorageUseViewModel.kt
类型 4:Tab 详情页
代表: GjcQueryDetailsActivity
结构: 自定义 Tab 栏 + ViewPager2 + 多 Fragment
Activity 骨架:
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("查询详情")
binding.viewModel = viewModel
viewModel.initOnCreated(intent)
// ViewPager2 配置
binding.vp.adapter = CustomVP2Adapter(viewModel.fragmentList, supportFragmentManager, lifecycle)
binding.vp.isUserInputEnabled = false // 禁用滑动
binding.vp.offscreenPageLimit = 3
// Tab 切换
viewModel.currentTab.observe(this) { binding.vp.setCurrentItem(it, false) }
viewModel.loadDetails()
}
companion object {
@JvmStatic
fun start(context: Context, id: Long?) {
context.startActivity(Intent(context, XxxDetailsActivity::class.java)
.putExtra(Constant.Key.ID, id?.toString() ?: ""))
}
}
ViewModel 骨架:
class XxxDetailsViewModel : BaseViewModel() {
val currentTab = MutableLiveData(0)
val fragmentList by lazy {
listOf(
FragmentA.newInstance(this),
FragmentB.newInstance(this),
FragmentC.newInstance(this)
)
}
fun onTabClick(index: Int) { currentTab.value = index }
}
Tab 布局模式:
<!-- 自定义 Tab(非 TabLayout) -->
<LinearLayout height="40dp" orientation="horizontal">
<LinearLayout width="100dp" gravity="center" onClick="@{()->viewModel.onTabClick(0)}" orientation="vertical">
<TextView text="运单信息"
textColor="@{viewModel.currentTab == 0 ? @color/colorPrimary : @color/text_gray}" />
<View height="3dp"
background="@{viewModel.currentTab == 0 ? @color/colorPrimary : @color/transparent}"
visibility="@{viewModel.currentTab == 0 ? View.VISIBLE : View.INVISIBLE}" />
</LinearLayout>
<!-- 更多 Tab... -->
</LinearLayout>
<ViewPager2 android:id="@+id/vp" layout_weight="1" />
参考文件: module_gjc/.../GjcQueryDetailsActivity.kt、GjcQueryDetailsViewModel.kt
类型 5:编辑表单页
代表: GjcQueryEditActivity
结构: ScrollView + PadDataLayoutNew 表单(只读+可编辑混合)+ 保存/取消
Activity 骨架:
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("运单修改")
binding.viewModel = viewModel
viewModel.initOnCreated(intent)
}
companion object {
@JvmStatic
fun start(context: Context, bean: XxxBean) {
context.startActivity(Intent(context, XxxEditActivity::class.java)
.putExtra(Constant.Key.DATA, Gson().toJson(bean)))
}
}
ViewModel 骨架:
class XxxEditViewModel : BaseViewModel() {
val dataBean = MutableLiveData(XxxBean())
val packageTypeList = MutableLiveData<List<KeyValue>>(emptyList())
fun initOnCreated(intent: Intent) {
val json = intent.getStringExtra(Constant.Key.DATA) ?: ""
if (json.isNotEmpty()) {
val bean = Gson().fromJson(json, XxxBean::class.java)
loadDropdownLists()
loadDetails(bean.id)
}
}
fun submit() {
val bean = dataBean.value ?: return
if (bean.wbNo.verifyNullOrEmpty("运单号不能为空")) return
launchLoadingCollect({ NetApply.api.updateXxx(bean.toRequestBody()) }) {
onSuccess = {
showToast("修改成功")
viewModelScope.launch { FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).emit("refresh") }
getTopActivity().finish()
}
}
}
fun cancel() { getTopActivity().finish() }
}
表单布局模式:
<ScrollView layout_weight="1" fillViewport="true">
<LinearLayout padding="15dp" orientation="vertical">
<LinearLayout background="@drawable/bg_white_radius_8" padding="15dp" orientation="vertical">
<!-- 三列表单行 -->
<LinearLayout orientation="horizontal">
<PadDataLayoutNew layout_weight="1" enable="@{false}" title='@{"运单号"}'
type="@{DataLayoutType.INPUT}" value='@{viewModel.dataBean.wbNo}' />
<PadDataLayoutNew layout_weight="1" enable="@{true}" title='@{"特码"}'
type="@{DataLayoutType.INPUT}" value='@={viewModel.dataBean.spCode}' />
<PadDataLayoutNew layout_weight="1" title='@{"包装类型"}'
type="@{DataLayoutType.SPINNER}" list="@{viewModel.packageTypeList}"
value='@={viewModel.dataBean.packageType}' />
</LinearLayout>
<!-- 备注(多行) -->
<PadDataLayoutNew inputHeight="@{80}" title='@{"备注"}'
type="@{DataLayoutType.INPUT}" value='@={viewModel.dataBean.remark}' />
</LinearLayout>
<!-- 底部按钮 -->
<LinearLayout gravity="center" marginTop="24dp">
<TextView style="@style/tv_bottom_btn" width="120dp" onClick="@{()-> viewModel.cancel()}" text="取消" />
<TextView style="@style/tv_bottom_btn" width="120dp" onClick="@{()-> viewModel.submit()}" text="保存" />
</LinearLayout>
</LinearLayout>
</ScrollView>
参考文件: module_gjc/.../GjcQueryEditActivity.kt、GjcQueryEditViewModel.kt
类型 6:添加表单页(含输入回调)
代表: GjcBoxWeighingAddActivity
结构: 类型 5 基础上 + setRefreshCallBack 输入完成回调 + 实时计算 + 扫码自动填充
与类型 5 的区别:
- 输入完成回调(关键特性):
<!-- 使用方法引用,不能用 Lambda -->
<PadDataLayoutNew
setRefreshCallBack="@{viewModel::onCarIdInputComplete}"
title='@{"架子车号"}' type="@{DataLayoutType.INPUT}" value='@={viewModel.carId}' />
private var lastQueriedCarId = ""
fun onCarIdInputComplete() {
val id = carId.value
if (!id.isNullOrEmpty() && id != lastQueriedCarId) {
lastQueriedCarId = id
queryFlatcarInfo(id) // 输入完成后自动查询
}
}
- 级联查询(航班日期+航班号同时有值时查询):
fun onFlightNoInputComplete() { queryFlightIfReady() }
fun onFlightDateInputComplete() { lastQueriedFlight = ""; queryFlightIfReady() }
private fun queryFlightIfReady() {
val fdate = flightDate.value; val fno = flightNo.value
if (!fdate.isNullOrEmpty() && !fno.isNullOrEmpty()) {
val key = "$fdate-$fno"
if (key != lastQueriedFlight) { lastQueriedFlight = key; queryFlightInfo(fdate, fno) }
}
}
- 实时计算:
fun initOnCreated(activity: Activity) {
totalWeight.observe(activity as LifecycleOwner) {
val total = it?.toDoubleOrNull() ?: 0.0
val net = total - (dataBean.value?.carWeight ?: 0.0)
val cargo = net - (dataBean.value?.uldWeight ?: 0.0)
netWeight.value = if (net > 0) net.toString() else "0"
cargoWeight.value = if (cargo > 0) cargo.toString() else "0"
}
}
- 输入限制:
// Activity 中设置
binding.carIdInput.et.setUpperCaseAlphanumericFilter()
- 重置功能:
fun resetClick() {
dataBean.value = XxxBean()
carId.value = ""; uldNo.value = ""; flightNo.value = ""
lastQueriedCarId = ""; lastQueriedUld = ""; lastQueriedFlight = ""
}
参考文件: module_gjc/.../GjcBoxWeighingAddActivity.kt、GjcBoxWeighingAddViewModel.kt
自定义 Dialog 开发模式
基于 BaseDialogModel(XPopup 封装),支持 5 种弹窗类型。
⚠️ 强制规则:所有二次确认弹框必须使用
ConfirmDialogModel(com.lukouguoji.module_base.model.ConfirmDialogModel),禁止使用系统AlertDialog。
基础模板
class XxxDialogModel(
private val onConfirm: () -> Unit
) : BaseDialogModel<DialogXxxBinding>(DIALOG_TYPE_CENTER) { // CENTER/BOTTOM/DRAWER/FULL
override fun layoutId() = R.layout.dialog_xxx
override fun onDialogCreated(context: Context) {
binding.model = this
}
fun onConfirmClick() { onConfirm(); dismiss() }
}
// 使用
XxxDialogModel(onConfirm = { refresh() }).show()
弹窗类型
| 类型 | 常量 | 场景 | 示例 |
|---|---|---|---|
| 底部 | DIALOG_TYPE_BOTTOM |
操作选项 | 默认类型 |
| 中间 | DIALOG_TYPE_CENTER |
确认框、表单输入 | ConfirmDialogModel |
| 抽屉 | DIALOG_TYPE_DRAWER |
通用右侧抽屉 | 见下方模板 |
| 全屏 | DIALOG_TYPE_FULL |
列表展示 | NoticeMessageDialogModel |
抽屉弹窗(筛选场景)
class XxxFilterDialogModel(
val filterField: MutableLiveData<String>,
private val onConfirm: () -> Unit
) : BaseDialogModel<DialogXxxFilterBinding>(DIALOG_TYPE_DRAWER) {
override fun onBuild(builder: XPopup.Builder) {
builder.popupPosition(PopupPosition.Right)
val width = DevUtils.getTopActivity().window.decorView.width / 3
builder.maxWidth(width).popupWidth(width)
}
override fun onDialogCreated(context: Context) {
binding.model = this
binding.lifecycleOwner = context as? LifecycleOwner
}
fun onResetClick() { filterField.value = "" }
fun onConfirmClick() { dismiss(); onConfirm() }
}
参考文件: module_base/.../BaseDialogModel.kt、module_gjc/.../dialog/
命名与文件组织
| 类型 | 命名 | 目录 |
|---|---|---|
| Activity | XxxActivity |
模块/page/ 或 模块/activity/ |
| ViewModel | XxxViewModel |
模块/viewModel/ |
| ViewHolder | XxxViewHolder |
模块/holder/ |
| Adapter | XxxAdapter |
模块/adapter/ |
| Bean | XxxBean |
module_base/bean/ |
| Dialog | XxxDialogModel |
模块/dialog/ |
| 布局 | activity_xxx.xml / item_xxx.xml / dialog_xxx.xml |
res/layout/ |
构建命令
./gradlew clean # 清理
./gradlew assembleDebug # Debug APK
./gradlew assembleRelease # Release APK(已签名)
./gradlew installDebug # 安装到设备
adb devices -l # 查看设备
adb logcat | grep "com.lukouguoji.aerologic" # 日志
DataBinding 关键规则
- lifecycleOwner 必须设置(
BaseBindingActivity已自动设置,手动使用时binding.lifecycleOwner = this) - 字符串拼接用反引号:
@{+`+姓名:+`++ viewModel.name} - LiveData 自动解包: XML 中直接
viewModel.dataBean.name,不写.value - 修改对象属性需重新赋值:
dataBean.value = dataBean.value?.copy(name = "新值") - 双向绑定用
@={}:value="@={viewModel.searchText}" - 点击事件用 Lambda:
onClick="@{() -> viewModel.submit()}" - setRefreshCallBack 用方法引用:
setRefreshCallBack="@{viewModel::methodName}"(不能用 Lambda) - 使用 View.VISIBLE/GONE 必须导入:
<import type="android.view.View" /> - textStyle 不支持 DataBinding: 只能用固定值如
android:textStyle="bold"
UI 组件速查
PadSearchLayout(搜索区)
| type | 用途 | 示例 |
|---|---|---|
SearchLayoutType.INPUT |
文本输入 | value="@={viewModel.flightNo}" |
SearchLayoutType.DATE |
日期选择 | value="@={viewModel.flightDate}" |
SearchLayoutType.SPINNER |
下拉选择 | list="@{viewModel.statusList}" value="@={viewModel.status}" |
支持扫码图标: icon="@{@mipmap/scan_code}" setOnIconClickListener="@{(v)-> viewModel.scan()}"
PadDataLayoutNew(表单区)
| type | 用途 | 关键属性 |
|---|---|---|
DataLayoutType.INPUT |
文本/多行输入 | enable, required, maxLength, inputHeight, hint |
DataLayoutType.SPINNER |
下拉选择 | list, hint |
DataLayoutType.DATE |
日期选择 | hint |
通用属性: title='@{"标题"}'、titleLength="@{5}"、value='@={viewModel.field}'
回调属性: setRefreshCallBack="@{viewModel::onInputComplete}"
completeSpace 对齐
completeSpace="@{5}" 设置 Key 文本宽度(以"一"字宽度为单位),用于 Key-Value 布局对齐。
AutoQuery 自动查询(PadSearchLayout / PadDataLayoutNew)
两个组件均支持输入时自动联想查询,只需在 XML 添加属性,无需修改 Kotlin:
<PadSearchLayout
autoQueryEnabled="@{true}"
autoQueryUrl="@{`/IntExpSearch/queryWbNoList`}"
autoQueryParamKey="@{`wbNo`}"
autoQueryMinLength="@{4}"
autoQueryMaxLength="@{8}"
autoQueryTitle="@{`选择运单号`}"
... />
- 1条结果 → 直接填充;多条结果 → 弹出选择列表;0条结果 → 无处理
- 通用 API 方法:
Api.getWbNoList(@Url url, @Body data)返回BaseResultBean<List<String>> - 关键文件:
module_base/.../ui/weight/data/layout/AutoQueryManager.kt
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 全部保持原样,无需任何设备判断。
⛔ 严禁:用「不同文件名 + 代码判断」切换布局
// ❌ 错误:会生成两个不同的 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 |
只在一个变体存在的 id,Binding 基类中会是可空字段,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 用法:
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() 再按形态锁定,不产生二次纠正:
<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/ 的竖屏值。后果是平板端:
- 先竖屏、随后被
BaseActivity纠正成横屏 —— 反而把闪屏问题搬到了平板上 - 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 + 底部白色面板,重置 / 确定各占一半 |
手机版页面改造步骤
- 移动平板布局:
git mv res/layout/activity_xxx.xml res/layout-sw600dp/activity_xxx.xml(item 布局同理) - 新建手机布局:在
res/layout/下创建同名文件,保持相同<variable>与共用控件 id - Kotlin 侧不动:
layoutId()、itemLayoutId、ViewHolder 全部保持原样 - 纯 UI 交互(Tab 切换、弹层显隐)写在 Activity,不污染 ViewModel;状态过滤优先复用 ViewModel 既有字段
- 手机布局顶部
<include layout="@layout/title_tool_bar" />不变(变体自动切换) - 弹框(BaseDialogModel)同样要适配:dialog 布局照样走同名双变体(Pad 版移
layout-sw600dp/、 手机版在layout/写设计稿的底部弹层样式),弹出位置在构造参数按形态切换:BaseDialogModel<XxxBinding>(if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER)(参考IntExpArriveResetDialogModel+dialog_int_exp_arrive_reset.xml双变体)。 页面适配完要逐个排查该页所有 DialogModel,漏掉的会在手机上弹出 Pad 居中样式 - 回到手机端首页加菜单入口(最易遗忘,见下节)——不加的话页面在手机上根本进不去
手机端首页菜单入口(适配完必须加,否则没入口)
手机端首页是 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,再去看清单。
# 看当前登录账号实际拥有的权限串
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 等业务模块,跨模块只能走路由):
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 已实现)
// Activity 中新增纯 UI 方法,写入 ViewModel 既有的 moveState 字段("" 全部 / "0" 未移库 / "1" 已移库)
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}"
手机版公共组件库(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、tagColor("orange" 默认/"green") |
PhoneFormRow |
行式表单项(左标签+右值+分隔线),PhoneFormRowType = INPUT/SELECT(内置底部选择面板)/DATE/TEXT(只读右对齐,空值 "--") |
title、hint、type、value(双向)、list、required、enable、numeric、setRefreshCallBack |
PhoneBottomBar 追加 actionDanger 属性:true 时主按钮转浅红底红字 + 垃圾桶图标(ULD 管理批量删除)。
新增资源:bg_phone_btn_danger、bg_phone_fab(圆形浮动 + 按钮)、bg_phone_tag_red_light(浅红标签)、
ic_phone_trash、ic_phone_edit、色值 phone_danger_bg/text。
出港计重追加:bg_phone_btn_weak(浅蓝弱按钮,卡片内提前运抵)、bg_phone_card_top_accent
(卡片顶部主色描边条)、ic_phone_clipboard(FAB 剪贴板)、ic_phone_weight(砝码图标,可 tint)。
出港查询追加:ic_phone_package(件数)、ic_phone_tag(特码)、bg_phone_dot_blue/gray(状态圆点);
PhoneFilterPanel 内容插槽已内置 ScrollView(筛选项超屏时收缩滚动,底部按钮始终可见——
9 个筛选项曾把「重置/确认」挤出屏幕)。
出港仓库追加:bg_phone_btn_disabled(灰底禁用按钮,与 bg_phone_btn_weak 同 10dp 圆角,
卡片操作按钮禁用态配 phone_tag_gray_text 文字)。
进港移库追加:bg_phone_btn_green(绿色主按钮,拍照上传「确认提交」)、bg_phone_card_header_orange
(橙色详情卡表头,异常照片)、bg_phone_photo_add(虚线加号槽位)、色值 phone_amber。
⚠️ 弹层与浮动按钮的层级:PhoneFilterPanel 默认无 elevation,页面里有带 elevation 的浮动按钮(FAB)时
会穿透到弹层之上——给弹层实例加 android:elevation="8dp"(大于 FAB 的 6dp)。
PhoneFilterPanel 自带内容插槽:把 PhoneDataLayout 直接写在其标签内即可,组件会自动移入面板中部
并施加 20dp 项间距,无需关心内部层级:
<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 的手机版组件都重写了:
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) = dispatchFreezeSelfOnly(container)
override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) = dispatchThawSelfOnly(container)
新增同类组件时必须照做(取值由 ViewModel + DataBinding 持有,本就不需要视图级恢复)。
已完成 / 待办
- ✅ 已完成:适配框架 + 手机端公共资源 + 7 个手机版公共组件
- ✅ 已完成页面:
- 「国际出港移库」
IntExpMoveActivity+ 「运单详情」IntExpMoveDetailActivity(框架参考页) - 「国际出港出库交接」
IntExpOutHandoverActivity+ 「板箱详情」IntExpOutHandoverDetailActivity(公共组件首个完整落地页,双端实机验证通过) - 「ULD管理」
UldListActivity+UldEditActivity(app 模块首个适配页;一套编辑布局覆盖 新增/修改/详情三态;批量删除为前端循环调用单条接口;状态三态/来源等 5 项后端缺口见问题清单 #7~#11) - 「出港计重」全链路 4+1 页:
GjcWeighingListActivity(待计重列表,三格统计 + 卡片单票提前运抵 + FAB 进记录)、GjcWeighingStartActivity(开始计重,托盘数量/自重按设计稿手输)、GjcWeighingRecordListActivity(计重记录)、GjcWeighingRecordDetailsActivity(计重明细, isEditMode 复用为手机全屏「修改记录」覆层)+ 手机专属新页GjcWeighingWbDetailActivity(运单详情)。 搜索分流规则(实测):含字母→fno、11 位数字→wbNo、其余→likeNo(likeNo 按 8 位 no 匹配并非模糊); checkIn 过滤等 7 项后端缺口见问题清单 #12~#18 - 「出港运抵」主列表 + 3 个手机专属新页:
IntExpArriveActivity(待运抵/已运抵 Tab,客户端按declareStatus是否为空拆分数据集,卡片长按浮层:待运抵→补充、已运抵→重置+回执)、IntExpArriveSubOrderActivity(分单明细,数据直接复用主列表已加载的haWbList,零新增请求)、IntExpArriveReceiptActivity(回执内容只读页,复用haWbList[].response)、IntExpArriveSupplementActivity(补充信息,参照 Pad 端国际进港「收发货人信息」批量应用模式, 后端接口不存在,实机 404 已确认,见问题清单 #19)。 Tab 过滤两个坑:① RecyclerView 里View.GONE不会让 item 塌陷为 0 高度(不同于普通 ViewGroup),必须在数据层整体refresh()过滤后的子集给 adapter,不能给 item 根节点加 visibility 绑定(实机截图验证过,见「常见编译错误速查」);② 通过Intent.putExtra(Serializable)传递的GjcHaWb/GjcMaWb,其checked/showMore/actionMenuVisible字段标了@Transient(= JVMtransient,Java 序列化会跳过),反序列化后为 null,直接.checked.set(...)会崩溃——接收方initOnCreated必须对列表.map { it.copy() }补上。 2026-07-30 review 修正:状态重置弹框改手机底部弹层(弹框双变体首例,见改造步骤第 6 条)、 主列表/回执页补空态、分单浮层补「回执」、分单统计加分母、Tab 角标初始 0 - 「提取出库」
IntImpPickUpDLVActivity(module_gjj,类型2 多选批量页 + 手机专属详情新页IntImpPickUpDetailActivity(Intent 传 Serializable bean 零请求)。待出库/已出库 Tab 绑 文档定义入参outState(0/1,抓包证实携带,效果待内网 #29)+ 角标双 pageQueryTotal; 双形态卡片按isPickedUp(dlvTime 非空)切换;手机进入时清空平板默认「当天缴费日期」查全量; 缺口 #28:无 fdate 入参、出参无车牌(提货车辆)/目的港/业务类型。 ⚠️ 踩坑:checkAllClick全选后底部「已选 N 项」不更新——批量勾选不走 FlowBus 单卡事件, 必须在 checkAllClick 里同步 selectedCountText) - 「进港查询」
IntImpQueryActivity(module_gjj;06 出港查询的进港镜像:三格统计 总票数/已入库/已出库为已加载数据客户端计数(pageQueryTotal无分项出参,缺口 #30), 卡片状态前端推断:dlvTime非空=已出库(绿)/inDate非空=已入库(橙)/兜底未入库(灰); 筛选弹层 9 项入参全部经 api-doc 核对真实存在(分页参数名page/limit,品名参数名goods);详情页与运单追踪复用 08/06 已适配页零新增;平板侧滑筛选面板代码已判空守卫。 ⚠️ 踩坑:代理人绑定不能写bean.agentName ?? bean.agentCode——转换器把 JSON null 归一成 空串,??只判 null 不判空串,须用 Bean 计算属性agentDisplay(06 出港查询同步修正)) - 「进港移库」
GnjMoveStashListActivity(app 模块,2026-08-03 归属更正后重做:设计稿 11 实际归属国内进港移库 DomImpMove(后端 8 接口齐全、Pad 首页「国内进港→进港移库」既有页面), 最初误判为国际进港并按 IntExpMove 约定预接了不存在的 IntImpMove 模块(原缺口 #31),该套 module_gjj 前端实现已整体删除。现行实现:主列表双形态(Pad 布局移 layout-sw600dp 零改动), 待移库/已移库 Tab 走search/searchMoved双端点(searchMoved出参无移库标识,前端按 端点给 bean 打isMoved),Tab 角标 = 两端点各调一次 limit=1 取分页 total(initPhoneExtras开关,仅手机发起);手机专属新页GnjMoveStashDetailActivity(Intent 传 bean 零请求,三卡片: 基本信息/移库信息(仅已移库)/异常照片)+GnjMoveStashPhotoActivity(进入先调detail回填 已有照片——searchMoved出参无 pic 三字段 #34,提交走modify最小体{mawbId,remark,picNumber,pic,originalPic},与平板移交编辑页同链路);批量移库复用平板moveStashClick(move:fid+ids)。手机首页入口在国内 Tab(GnViewModel,权限串GnjYiKu=AppDomImpMove,需补通用 route 跳转逻辑,图标从 module_gnj 复制); GnjMoveStashListActivity 补 @Route/app/GnjMoveStashListActivity。 2026-08-03 内网真实数据双端验证通过;筛选 A/B:wbNo/awbType生效,fdate/fno/spCode被后端静默忽略(#32)、opId/opDate恒 null(#33)) - 「进港仓库」
IntImpStorageUseActivity(module_gjj,07 出港仓库的进港镜像;首页菜单实际跳转页, 旧版GjjWareHouseActivity路由已注释弃用。差异:卡片/筛选用始发站(fdep 入参)替代目的站; 搜索框「运单号/航班号」分流(含字母→fno、其余→wbNo,抓包证实);筛选 5 项无入参(缺口 #25, 比出港多 2 项);连带首次适配进港详情IntImpQueryDetailsActivity+ 3 Fragment(报文区 3 项: 原始舱单/理货报告/海关放行,件重区直取 inPc/inWeight 无需求和,detail keys 以平板绑定为准 + dep/dest1/dest/unNumber/opDate 按出港推断待实测 #27);修改库位手机弹层「原库位」只列未出库 库位(沿用平板校验);前端接口前缀是IntImpStorage不是IntImpStorageUse,api-doc 按 后者搜不到) - 「出港仓库」
IntExpStorageUseActivity(嵌套多选列表页型的首个适配:平板「勾选主/子列表 + 底部批量操作」在手机上改为卡片级四按钮(清仓/修改库位/出库/入库,已清仓置灰),语义等价 「勾选该主单+全部库位」直接复用既有 performXxx 链路,ViewHolder 对双侧独有 id 全判空; 三格统计 总票数/未清仓/已清仓(后两格 = 同条件 +clearNormal=0/1各调一次 pageQueryTotal, 过滤是否生效待内网 A/B,缺口 #24);筛选弹层 8 项中运单类型/业务类型/品名(中) 为后端未定义 入参(缺口 #23,预留传参);三个操作弹框双变体化 + 手机版修改库位弹层加「原库位」单选; 出库确认 AlertDialog → ConfirmDialogModel;详情与运单追踪完全复用出港查询已适配页,零新增页面) - 「出港查询」
GjcQueryActivity(列表:三格统计 总票数/已入库/已离港 + 卡片状态标签, 状态为前端推断:fclose非空=已离港/opDate非空=已入库;筛选弹层 9 项,日期起止与代理 从平板搜索行并入,特码改下拉getSpecialCodeList(flag=1, ieFlag="");分项统计为已加载 数据客户端计数,isFclose实测被后端忽略,缺口 #21)+GjcQueryDetailsActivity(3 Tab 复用 ViewPager2+Fragment,手机变体 PhoneKvItem 网格;入库件数/重量= warehouseList 求和; 锁定状态/计费重量后端无字段,缺口 #22)+ 「运单追踪」LogDetailActivity(app 模块公共页 双变体首例:手机竖排时间线由 ActivitybuildVerticalSteps动态构建,平板横向步骤条不动; 手机标题按形态切「运单追踪」)。平板侧滑筛选面板代码已判空守卫(手机变体无 filter_panel include,Binding 字段为 @Nullable)
- 「国际出港移库」
- ✅ 设计稿 11 个入口(01~11)已于 2026-07-31 全部适配完成
- ⏳ 待办:
PhoneSearchBar接入 AutoQuery 联想(AutoQueryManager目前只对接了 Pad 系组件)- 进港移库筛选三项(航班日期/航班号/特码)待后端实装
fdate/fno/spCode入参(缺口 #32)、 移库人/移库时间待后端回填opId/opDate并补姓名出参(缺口 #33)后自动生效 - 出库交接 Tab 角标恒 0:
IntExpOutHandover/pageQueryTotal出参恒 0(后端缺陷,问题清单 #6), 后端修复后角标自动恢复(handoverState过滤本身已于 2026-07-29 实机验证通过)生效
- 📋 接口对接:适配中发现的后端接口缺口统一记录在
documents/后端接口对接问题清单.xlsx(按模块/页面/接口定义/接口地址索引,与后端对接的正式文档,每页适配完必须维护); 接口入参/出参一律以 api-doc MCP 为准核对,流程见.claude/skills/phone-adapt/references/api-doc.md——后端对不认识的参数静默忽略, 猜参数名会得出「后端不支持」的误判(出库交接hoState→handoverState已踩过)
调试便利:直接拉起页面
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
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 路径速查
基类与常用类
| 类 | 正确路径 |
|---|---|
BaseActivity |
com.lukouguoji.module_base.base.BaseActivity |
BaseBindingActivity |
com.lukouguoji.module_base.base.BaseBindingActivity |
BaseViewModel |
com.lukouguoji.module_base.base.BaseViewModel |
BasePageViewModel |
com.lukouguoji.module_base.base.BasePageViewModel |
BaseViewHolder |
com.lukouguoji.module_base.base.BaseViewHolder |
BaseDialogModel |
com.lukouguoji.module_base.base.BaseDialogModel |
CustomVP2Adapter |
com.lukouguoji.module_base.base.CustomVP2Adapter |
Constant |
com.lukouguoji.module_base.common.Constant |
DetailsPageType |
com.lukouguoji.module_base.common.DetailsPageType |
ConstantEvent |
com.lukouguoji.module_base.common.ConstantEvent |
NetApply |
com.lukouguoji.module_base.http.net.NetApply |
FlowBus |
com.lukouguoji.module_base.impl.FlowBus |
observe(FlowBus 扩展) |
com.lukouguoji.module_base.impl.observe |
IOnItemClickListener |
com.lukouguoji.module_base.interfaces.IOnItemClickListener |
ARouterConstants |
com.lukouguoji.module_base.router.ARouterConstants |
扩展函数(均在 com.lukouguoji.module_base.ktx 包下)
launchCollect、launchLoadingCollect、showToast、toRequestBody、verifyNullOrEmpty、noNull、formatDate、setUpperCaseAlphanumericFilter
工具类
| 类 | 正确路径 |
|---|---|
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 |
DateUtils |
dev.utils.common.DateUtils |
SharedPreferenceUtil |
com.lukouguoji.module_base.db.perference.SharedPreferenceUtil |
ScanModel |
com.lukouguoji.module_base.model.ScanModel |
ConfirmDialogModel |
com.lukouguoji.module_base.model.ConfirmDialogModel |
常见编译错误速查
| 错误 | 原因 | 修复 |
|---|---|---|
DetailsPageType 找不到 |
包名错误 | common.DetailsPageType,非 constant. |
DataLayoutType.INTEGER |
不存在 | 用 DataLayoutType.INPUT |
DetailsPageType.Edit |
不存在 | 用 DetailsPageType.Modify |
IOnItemClickListener 找不到 |
包名错误 | interfaces.,非 impl. |
FlowBus.observe 无法调用 |
未导入扩展 | 单独导入 com.lukouguoji.module_base.impl.observe |
FlowBus.emit() 报错 |
需在协程中 | viewModelScope.launch { FlowBus.with<String>(...).emit(...) } |
| 图片上传字段错误 | 无 url 字段 |
用 result.data?.newName |
pageType 绑定失效 |
非 LiveData | 用 MutableLiveData(DetailsPageType.Add) |
RecyclerView items 报错 |
不支持该属性 | Activity 中手动 rv.commonAdapter()?.refresh(data) |
| 资源引用不存在 | 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(见「屏幕方向」小节的 ⛔ 说明) |
| Tab/分类切换后列表顶部留一大块空白,卡片数量却不对 | 给 item 根节点加了 visibility="@{tab条件 ? VISIBLE : GONE}"——RecyclerView/LinearLayoutManager 不会像普通 ViewGroup 那样让 GONE 的子项自动塌陷为 0 高度,仍按测量高度参与滚动区域计算 |
不要用 item 级 visibility 做过滤;在 ViewModel 里自行维护完整列表,Tab 切换时把过滤后的子集整体 commonAdapter()?.refresh(filteredList) 给 adapter |
通过 Intent.putExtra(Serializable) 传递的 Bean,切换其 checked/showMore 等状态时崩溃 NullPointerException: ... on a null object reference |
这些字段标了 @Transient(= JVM transient,本意是避免 Gson 网络请求序列化 ObservableBoolean),但 Java 对象序列化(Intent 传递走的机制)会跳过 transient 字段的恢复,反序列化后是 null |
接收方 initOnCreated 里对列表做 .map { it.copy() }——.copy() 会重新走一遍类体初始化逻辑,用全新 ObservableBoolean(false) 补上 |
布局构建报 Error: The string "--" is not permitted within comments |
XML 注释中出现连续双横线(如写"兜底 "--""),XML 规范禁止 | 注释里改用文字描述(如「兜底双横线」),代码绑定表达式中的 "--" 不受影响 |
| 多选页手机版点「全选」后底部「已选 N 项」不更新(单卡勾选正常) | 批量勾选在 checkAllClick 里直接 set,不走 FlowBus 的单卡 EVENT_CHECK_CHANGED 事件,selectedCountText 只在事件回调里更新 |
checkAllClick 末尾自行同步 selectedCountText(平板布局不绑定该字段,无影响) |
XML 里 bean.a ?? bean.b 兜底不生效,字段明明有值却显示占位符 |
网络层转换器把 JSON null 归一成空串 "",DataBinding 的 ?? 只判 null 不判空串,永远取到左侧的空串 |
在 Bean 上加计算属性做 isNullOrEmpty 双兜底(如 agentDisplay),XML 绑计算属性(进港查询/出港查询代理人已踩过) |
编辑表单下拉框(SPINNER)回填规范
编辑页面(DetailsPageType.Modify)中,下拉框需要根据已有数据自动选中对应项。必须使用 DictUtils 的 checkedValue 参数,禁止依赖组件自动匹配 value。
原理
DictUtils 的 handleCallBack 会将 checkedValue 匹配的 KeyValue 置于列表首位。PadDataLayoutNew 的 SPINNER 默认显示列表第 0 项,因此匹配项自动成为选中项,无需额外设置 selectedIndex。
标准做法(参考 GjjManifestDetailsViewModel、GjjManifestAddViewModel)
- 字典加载必须在编辑数据加载之后(不能放在
init中),确保checkedValue可用 - 编辑模式传入
checkedValue,新增模式传null - 编辑模式不预置空
KeyValue("", "")(否则空项会占据首位,覆盖 checkedValue 排序)
fun initOnCreated(intent: Intent) {
// 1. 先解析页面类型和编辑数据
if (pageType.value == DetailsPageType.Modify) {
loadManifestFromBean(bean) // 设置 agent.value、specialCode.value 等
}
// 2. 再加载字典列表(此时 checkedValue 已可用)
loadDictLists()
}
private fun loadDictLists() {
val isModify = pageType.value == DetailsPageType.Modify
DictUtils.getXxxList(
addAll = false,
checkedValue = if (isModify) field.value else null // 编辑模式传值,新增传 null
) {
xxxList.postValue(if (isModify) it else listOf(KeyValue("", "")) + it)
}
}
checkedValue 取值规则
提交时用的哪个字段值,checkedValue 就传哪个。对照 toKeyValue() 的 value 字段确认匹配:
| DictUtils 方法 | KeyValue.value 来源 | checkedValue 示例 |
|---|---|---|
通用(handleCallBack) |
DictBean.code |
manifest.agentCode(如 "SFINT") |
getShouYunPackageTypeList |
PackageBean.name |
manifest.packageType(如 "木框") |
禁止做法
- ❌ 在
init中加载字典(编辑数据尚未可用,无法传checkedValue) - ❌ 依赖
PadDataLayoutNew的value属性自动匹配列表(Spinner adapter 重建时onItemSelected回调会覆盖已有值) - ❌ 编辑模式下在列表前添加
KeyValue("", "")(会干扰checkedValue置顶排序)
开发检查清单
新页面开发必做
- 创建 Bean(如需)→ API 接口 → ViewHolder(列表页)→ ViewModel → Activity → 布局
- 在
app/src/main/AndroidManifest.xml注册 Activity(最易遗忘):
<activity android:name="com.lukouguoji.gjc.activity.XxxActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:screenOrientation="unspecified" />
screenOrientation必须写unspecified,不要写死userLandscape、也不要用资源引用。 写死横屏会让手机端「先横屏、约 1 秒后转竖屏」闪一下;资源引用则会让平板端出问题 (详见下方「屏幕方向」小节)。
- 在
ARouterConstants注册路由(如需) - 标题栏统一用
<include layout="@layout/title_tool_bar" />,Activity 中setBackArrow("标题") - 双形态布局:需同时支持手机的页面,平板布局放
res/layout-sw600dp/、手机布局放res/layout/,两者同名且变量与共用 id 一致。详见「5.5 寸手持端双形态适配规范」
常见业务操作
扫码:
fun scanWaybill() { ScanModel.startScan(getTopActivity(), Constant.RequestCode.WAYBILL) }
// Activity.onActivityResult 中: waybillNo.value = data?.getStringExtra(Constant.Result.CODED_CONTENT)
图片上传: UploadUtil.upload(filePath) → result.data?.newName(注意是 newName 非 url)
刷新事件:
// 发送(ViewModel 中,必须在协程中)
viewModelScope.launch { FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).emit("refresh") }
// 接收(Activity 中,必须导入 observe)
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).observe(this) { viewModel.refresh() }
静态启动方法:
companion object {
@JvmStatic
fun start(context: Context, id: String) {
context.startActivity(Intent(context, XxxActivity::class.java).putExtra(Constant.Key.ID, id))
}
}
API 接口目录对应规则
为某页面查找接口时,必须按业务路径匹配对应 API 目录,不能跨模块借用。
| API 前缀 | 所属模块 |
|---|---|
IntImpManiFest/ |
国际进港-进港舱单(增删改查) |
IntImpAirManifest/ |
国际进港-原始舱单(申报、补充信息等) |
不同前缀代表不同业务,即使功能语义相似(如"更新"),也不能混用。不确定时询问用户。
页面定位规则
修改代码前,必须确认目标文件是首页菜单实际跳转到的 Activity/ViewModel,而非同名旧版文件。同一业务有多个实现时,以首页菜单入口链路为准。
图片上传与展示规范
图片上传三字段规范
上传图片后提交表单时,必须同时传 pic、originalPic、picNumber 三个字段,缺一不可。
UploadUtil.upload() 返回值(注意:与字面意思相反):
data?.newName— 原图文件名(较大)data?.zipFileName— 缩略图/压缩图文件名(较小)
提交时字段映射(参考事故签证 AccidentVisaDetailsViewModel、IntImpAccidentVisaEditViewModel):
// FileBean 字段含义(约定用途,与 UploadBean 字段名不一致):
// - FileBean.url 作缩略图标识(提交到 bean.pic)
// - FileBean.originalPic 作原图标识(提交到 bean.originalPic)
// 上传新图片(注意 UploadBean 字段名的误导性,按实际含义赋值)
val data = UploadUtil.upload(fileBean.path).data
fileBean.url = data?.zipFileName ?: "" // 缩略图
fileBean.originalPic = data?.newName ?: "" // 原图
// 提交时设置三个字段
bean.picNumber = list.size.toString()
bean.pic = list.joinToString(",") { MediaUtil.removeUrl(it.url) } // 缩略图
bean.originalPic = list.joinToString(",") { MediaUtil.removeUrl(it.originalPic) } // 原图
常见错误:
- ❌ 只传
images或originalPic单个字段 — 接口不认或数据不完整 - ❌ 只取
newName不取zipFileName— 丢失缩略图/原图之一 - ❌ 按
UploadBean字段字面含义赋值(url = newName)— 会导致 pic/originalPic 内容和字段语义颠倒(缩略图字段装原图、原图字段装缩略图) - ❌ 用
fileBean.path.startsWith("http")判断已上传 — 应该用fileBean.url.isNotEmpty()
编辑页加载已有图片
从详情接口获取图片后,需要同时解析 pic(缩略图)和 originalPic(原图),构建完整的 FileBean:
val picList = bean.pic.split(",").filter { it.isNotEmpty() }
val originalPicList = bean.originalPic.split(",").filter { it.isNotEmpty() }
val images = picList.mapIndexed { index, picUrl ->
val originalFile = originalPicList.getOrElse(index) { picUrl }
FileBean(
path = MediaUtil.fillUrl(picUrl), // 完整URL,用于显示
url = picUrl, // 相对路径,提交时用
originalPic = MediaUtil.fillUrl(originalFile) // 原图完整URL
)
}.toMutableList()
图片加载必须带 Authorization Header
/file/getImg/ 接口需要鉴权,Glide 默认不带 token,直接用 loadImage BindingAdapter 会 403 Forbidden。
正确做法 — 在 ViewHolder 中使用 GlideUrl + LazyHeaders:
// 缩略图加载(ViewHolder 中)
val glideUrl = GlideUrl(
bean.path,
LazyHeaders.Builder()
.addHeader("Authorization", SharedPreferenceUtil.getString(Constant.Share.token))
.build()
)
Glide.with(itemView.context).load(glideUrl).into(binding.ivThumbnail)
同时必须去掉 XML 布局中的 loadImage 属性,否则 BindingAdapter 会触发不带 token 的请求覆盖手动加载:
<!-- ❌ 错误:会触发不带 token 的 Glide 请求 -->
<ImageView loadImage="@{bean.path}" />
<!-- ✅ 正确:只保留 id,由 ViewHolder 手动加载 -->
<ImageView android:id="@+id/iv_thumbnail" />
大图预览同理 — PreviewImageViewHolder 也需要用 GlideUrl 带 token 加载网络图片。
参考文件:
- 缩略图加载:
module_gjj/.../GjjManifestPicViewHolder.kt - 大图预览:
module_base/.../PreviewImageViewHolder.kt - 图片上传提交:
app/.../AccidentVisaDetailsViewModel.kt - 带 token 的 Glide 加载:
module_mit/.../PictureAdapter.kt
开发原则
- 资源引用必须存在 — 创建布局前确认 drawable/color/string 资源真实存在或主动创建
- 标题栏统一用
title_tool_bar— 禁止手动编写 Toolbar - 优先使用 PadDataLayoutNew 和 PadSearchLayout 组件
- 在每个页面布局时,如有截图,务必尽可能还原图片上的页面设计,而不是推测假想。如有困难一律要询问,禁止自己想象
- 工具栏图标尺寸规范:
img_search36dp + padding 2dpimg_add40dp 无 padding(使用drawable/img_add.xml矢量图,drawable-xhdpi/img_add.png已废弃删除)img_delete36dp + padding 4dpic_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,禁止直接写死色值
环境配置
- 服务器:
module_base/.../res/values/strings.xml中system_url_inner/weight_url - 签名:
key.jks(根目录),密码123321,别名key - 模块独立运行:
gradle.properties中isBuildModule=true
错误排查流程
- Import 错误 → 查上方 Import 速查表
- 资源引用错误 → 检查 drawable/color/string 是否存在
- DataBinding 错误 → 检查 import、枚举值、View 类导入
- suspend function 错误 → 在
viewModelScope.launch中调用 - 双形态布局问题 → 查「常见编译错误速查」中 3 条布局变体相关条目;可用
DeviceUtil.describe()打日志确认当前形态 - 仍有问题 →
./gradlew clean后重新构建
双端验证命令
~/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/ 公共组件后,必须在平板上回归,确认现有平板版行为不变。