# 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 骨架**: ```kotlin @Route(path = ARouterConstants.ACTIVITY_URL_XXX) class XxxActivity : BaseBindingActivity() { 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(ConstantEvent.EVENT_REFRESH).observe(this) { viewModel.refresh() } viewModel.refresh() } } ``` **ViewModel 骨架**: ```kotlin 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() } } } } ``` **布局结构**: ```xml ``` **参考文件**: `module_gjc/.../GjcBoxWeighingActivity.kt`、`GjcBoxWeighingViewModel.kt` #### 列表项布局规范 (`item_xxx.xml`) **整体结构**: 水平 LinearLayout → 左侧图标 + 中间内容区(多行 KV)+ 右侧箭头 ```xml ``` **单个 KV 组件模板**: ```xml ``` **关键对齐规则**: | 规则 | 说明 | 示例 | |------|------|------| | **列 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 的区别**: 1. **Bean 增加 ObservableBoolean**: ```kotlin class XxxBean { val checked: ObservableBoolean = ObservableBoolean(false) var isSelected: Boolean get() = checked.get() set(value) = checked.set(value) } ``` 2. **ViewModel 增加全选逻辑**: ```kotlin val isAllChecked = MutableLiveData(false) init { isAllChecked.observeForever { checked -> val list = pageModel.rv?.commonAdapter()?.items as? List ?: return@observeForever list.forEach { it.checked.set(checked) } pageModel.rv?.commonAdapter()?.notifyDataSetChanged() } } fun checkAllClick() { val list = pageModel.rv?.commonAdapter()?.items as? List ?: 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) ?.filter { it.isSelected } ?: return if (selected.isEmpty()) { showToast("请选择数据"); return } launchLoadingCollect({ NetApply.api.batchXxx(selected.toRequestBody()) }) { onSuccess = { showToast("操作成功"); refresh() } } } ``` 3. **Activity 观察全选图标**: ```kotlin viewModel.isAllChecked.observe(this) { binding.checkIcon.alpha = if (it) 1.0f else 0.5f } ``` 4. **Item 布局图片切换**: ```xml ``` 5. **ViewHolder 中处理点击**: ```kotlin binding.ivIcon.setOnClickListener { bean.checked.set(!bean.checked.get()) binding.executePendingBindings() } ``` **参考文件**: `module_gjc/.../IntExpOutHandoverActivity.kt`、`IntExpOutHandoverViewModel.kt` --- ### 类型 3:嵌套多选列表页 **代表**: `IntExpStorageUseActivity` **结构**: 主列表含子列表(展开/收起)+ 主子联动全选 + Dialog 操作 **与类型 2 的区别**: 1. **Activity 暴露给布局**(用于调用 Dialog 方法): ```kotlin binding.activity = this // XML 中可调用 activity.showXxxDialog() ``` 2. **联动全选(主+子列表)**: ```kotlin isAllChecked.observeForever { checked -> val list = pageModel.rv?.commonAdapter()?.items as? List ?: return@observeForever list.forEach { it.checked.set(checked) it.storageUseList?.forEach { sub -> sub.checked.set(checked) } } } ``` 3. **全局展开/收起**: ```kotlin val isAllExpanded = MutableLiveData(false) fun toggleAllExpand() { val shouldExpand = !isAllExpanded.value!! isAllExpanded.value = shouldExpand (pageModel.rv?.commonAdapter()?.items as? List)?.forEach { if (!it.storageUseList.isNullOrEmpty()) it.showMore.set(shouldExpand) } pageModel.rv?.commonAdapter()?.notifyDataSetChanged() } ``` 4. **子列表项 checkbox 样式**(必须使用 `_style` 系列,禁止使用 `_gray` 系列): ```xml ``` | 资源 | 含义 | 外观 | |------|------|------| | `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"` **子列表复选框(关键)**: ```xml ``` > `loadImage` 和 `android:src` **均须**使用 `_style` 系列,**禁止**使用 `_gray` 系列(参见上方复选框样式表) **参考文件**: `module_gjc/.../IntExpStorageUseActivity.kt`、`IntExpStorageUseViewModel.kt` --- ### 类型 4:Tab 详情页 **代表**: `GjcQueryDetailsActivity` **结构**: 自定义 Tab 栏 + ViewPager2 + 多 Fragment **Activity 骨架**: ```kotlin 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 骨架**: ```kotlin 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 布局模式**: ```xml ``` **参考文件**: `module_gjc/.../GjcQueryDetailsActivity.kt`、`GjcQueryDetailsViewModel.kt` --- ### 类型 5:编辑表单页 **代表**: `GjcQueryEditActivity` **结构**: ScrollView + PadDataLayoutNew 表单(只读+可编辑混合)+ 保存/取消 **Activity 骨架**: ```kotlin 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 骨架**: ```kotlin class XxxEditViewModel : BaseViewModel() { val dataBean = MutableLiveData(XxxBean()) val packageTypeList = MutableLiveData>(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(ConstantEvent.EVENT_REFRESH).emit("refresh") } getTopActivity().finish() } } } fun cancel() { getTopActivity().finish() } } ``` **表单布局模式**: ```xml ``` **参考文件**: `module_gjc/.../GjcQueryEditActivity.kt`、`GjcQueryEditViewModel.kt` --- ### 类型 6:添加表单页(含输入回调) **代表**: `GjcBoxWeighingAddActivity` **结构**: 类型 5 基础上 + `setRefreshCallBack` 输入完成回调 + 实时计算 + 扫码自动填充 **与类型 5 的区别**: 1. **输入完成回调**(关键特性): ```xml ``` ```kotlin private var lastQueriedCarId = "" fun onCarIdInputComplete() { val id = carId.value if (!id.isNullOrEmpty() && id != lastQueriedCarId) { lastQueriedCarId = id queryFlatcarInfo(id) // 输入完成后自动查询 } } ``` 2. **级联查询**(航班日期+航班号同时有值时查询): ```kotlin 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) } } } ``` 3. **实时计算**: ```kotlin 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" } } ``` 4. **输入限制**: ```kotlin // Activity 中设置 binding.carIdInput.et.setUpperCaseAlphanumericFilter() ``` 5. **重置功能**: ```kotlin 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`。 ### 基础模板 ```kotlin class XxxDialogModel( private val onConfirm: () -> Unit ) : BaseDialogModel(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` | ### 抽屉弹窗(筛选场景) ```kotlin class XxxFilterDialogModel( val filterField: MutableLiveData, private val onConfirm: () -> Unit ) : BaseDialogModel(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/` | --- ## 构建命令 ```bash ./gradlew clean # 清理 ./gradlew assembleDebug # Debug APK ./gradlew assembleRelease # Release APK(已签名) ./gradlew installDebug # 安装到设备 adb devices -l # 查看设备 adb logcat | grep "com.lukouguoji.aerologic" # 日志 ``` --- ## DataBinding 关键规则 1. **lifecycleOwner 必须设置**(`BaseBindingActivity` 已自动设置,手动使用时 `binding.lifecycleOwner = this`) 2. **字符串拼接用反引号**: `@{` + `` ` `` + `姓名:` + `` ` `` + ` + viewModel.name}` 3. **LiveData 自动解包**: XML 中直接 `viewModel.dataBean.name`,不写 `.value` 4. **修改对象属性需重新赋值**: `dataBean.value = dataBean.value?.copy(name = "新值")` 5. **双向绑定用 `@={}`**: `value="@={viewModel.searchText}"` 6. **点击事件用 Lambda**: `onClick="@{() -> viewModel.submit()}"` 7. **setRefreshCallBack 用方法引用**: `setRefreshCallBack="@{viewModel::methodName}"`(不能用 Lambda) 8. **使用 View.VISIBLE/GONE 必须导入**: `` 9. **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: ```xml ``` - 1条结果 → 直接填充;多条结果 → 弹出选择列表;0条结果 → 无处理 - 通用 API 方法:`Api.getWbNoList(@Url url, @Body data)` 返回 `BaseResultBean>` - 关键文件:`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` 全部保持原样,无需任何设备判断**。 ### ⛔ 严禁:用「不同文件名 + 代码判断」切换布局 ```kotlin // ❌ 错误:会生成两个不同的 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 变量必须完全一致** | 两个变体 `` 中的 `` 名称与类型必须相同,否则编译失败 | | **共用控件 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` 用法: ```kotlin 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()` 再按形态锁定,不产生二次纠正: ```xml ``` 全项目 192 处已统一(`module_p` 等 24 处本就是 `portrait` 的 PDA 页面保持不变)。 #### ⛔ 别用 `@integer/screen_orientation` 这类资源引用(已验证行不通) 一度改成 `android:screenOrientation="@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/` 的值 → 证明限定符被忽略。 ### ⚠️ 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` + 底部白色面板,重置 / 确定各占一半 | ### 手机版页面改造步骤 1. **移动平板布局**:`git mv res/layout/activity_xxx.xml res/layout-sw600dp/activity_xxx.xml`(item 布局同理) 2. **新建手机布局**:在 `res/layout/` 下创建**同名**文件,保持相同 `` 与共用控件 id 3. **Kotlin 侧不动**:`layoutId()`、`itemLayoutId`、ViewHolder 全部保持原样 4. **纯 UI 交互**(Tab 切换、弹层显隐)写在 Activity,不污染 ViewModel;状态过滤优先复用 ViewModel 既有字段 5. 手机布局顶部 `` 不变(变体自动切换) 6. **回到手机端首页加菜单入口**(最易遗忘,见下节)——不加的话页面在手机上根本进不去 ### 手机端首页菜单入口(适配完必须加,否则没入口) 手机端首页是 `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,再去看清单。 ```bash # 看当前登录账号实际拥有的权限串 adb -s 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` 等业务模块,跨模块只能走路由): ```kotlin 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` 已实现) ```kotlin // Activity 中新增纯 UI 方法,写入 ViewModel 既有的 moveState 字段("" 全部 / "0" 未移库 / "1" 已移库) 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}" ``` ### 手机版公共组件库(`module_base/.../ui/weight/phone/`) 7 个组件覆盖设计稿中反复出现的部件,**新页面优先复用,不要重写 XML**。属性均为无命名空间写法, DataBinding 适配器统一放在 `PhoneWidgetKtx.kt`(同名属性按控件类型解析,与平板端 Pad* 组件互不冲突)。 | 组件 | 用途 | 关键属性 | |------|------|----------| | `PhoneSearchBar` | 顶部搜索行:搜索框 + 扫码 + 圆形筛选按钮 | `hint`、`value`(双向)、`showScan`、`showFilter`、`upperCase`、`setOnScanClickListener`、`setOnFilterClickListener`、`setRefreshCallBack` | | `PhoneStatusTab` | 状态 Tab 条(文字 + 角标,选中态下划线) | `tabs`(List\)、`counts`(List\)、`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` | | `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)。 ⚠️ **弹层与浮动按钮的层级**:`PhoneFilterPanel` 默认无 elevation,页面里有带 elevation 的浮动按钮(FAB)时 会穿透到弹层之上——给弹层实例加 `android:elevation="8dp"`(大于 FAB 的 6dp)。 **`PhoneFilterPanel` 自带内容插槽**:把 `PhoneDataLayout` 直接写在其标签内即可,组件会自动移入面板中部 并施加 20dp 项间距,无需关心内部层级: ```xml ``` #### ⚠️ 复合自定义 View 必须屏蔽子控件状态恢复(已踩坑) 同一页面放多个 `PhoneDataLayout`/`PhoneStatBox` 时,它们的内部子控件(`et`/`tv`/`spinner`…)**共用同一套 id**。 系统按 id 保存/恢复视图状态会跨实例串档 —— 实测表现为:DATE 项的默认日期被邻近输入框恢复的空文本清空, 并通过双向绑定把 ViewModel 字段也写成了空值(筛选条件静默丢失)。 因此每个含内部 id 的手机版组件都重写了: ```kotlin override fun dispatchSaveInstanceState(container: SparseArray) = dispatchFreezeSelfOnly(container) override fun dispatchRestoreInstanceState(container: SparseArray) = 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`(= JVM `transient`,Java 序列化会跳过),反序列化后为 null,直接 `.checked.set(...)` 会崩溃——接收方 `initOnCreated` 必须对列表 `.map { it.copy() }` 补上 - ⏳ **待办**: - `PhoneSearchBar` 接入 AutoQuery 联想(`AutoQueryManager` 目前只对接了 Pad 系组件) - 出库交接 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"`,免去每次登录再逐级点入: ```bash 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 里临时注入几条样例 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(...).emit(...) }` | | 图片上传字段错误 | 无 `url` 字段 | 用 `result.data?.newName` | | `pageType` 绑定失效 | 非 LiveData | 用 `MutableLiveData(DetailsPageType.Add)` | | `RecyclerView items` 报错 | 不支持该属性 | Activity 中手动 `rv.commonAdapter()?.refresh(data)` | | 资源引用不存在 | drawable/color/string 缺失 | 先检查资源是否存在,不存在则创建或用已有资源 | | `View.VISIBLE` 报错 | 未导入 | XML `` 中加 `` | | `textStyle` DataBinding 报错 | 不支持 | 用固定值 `android:textStyle="bold"` | | 手机版页面一进入就 `ClassCastException` | 用了「不同文件名 + 代码判断」切换布局,生成了两个 Binding 类 | 改用同名布局 + 资源限定符(`layout/` 与 `layout-sw600dp/`),见「5.5 寸手持端双形态适配规范」 | | 布局变体编译报 `variable not found` | 两个变体的 `` 中 `` 不一致 | 手机与平板变体的变量名/类型必须完全相同 | | 手机上元素小一半 / 布局错位 | 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)` 补上 | --- ## 编辑表单下拉框(SPINNER)回填规范 编辑页面(DetailsPageType.Modify)中,下拉框需要根据已有数据自动选中对应项。**必须使用 `DictUtils` 的 `checkedValue` 参数**,禁止依赖组件自动匹配 value。 ### 原理 `DictUtils` 的 `handleCallBack` 会将 `checkedValue` 匹配的 `KeyValue` 置于列表首位。`PadDataLayoutNew` 的 SPINNER 默认显示列表第 0 项,因此匹配项自动成为选中项,无需额外设置 selectedIndex。 ### 标准做法(参考 `GjjManifestDetailsViewModel`、`GjjManifestAddViewModel`) 1. **字典加载必须在编辑数据加载之后**(不能放在 `init` 中),确保 `checkedValue` 可用 2. **编辑模式传入 `checkedValue`**,新增模式传 `null` 3. **编辑模式不预置空 `KeyValue("", "")`**(否则空项会占据首位,覆盖 checkedValue 排序) ```kotlin 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` 置顶排序) --- ## 开发检查清单 ### 新页面开发必做 1. 创建 Bean(如需)→ API 接口 → ViewHolder(列表页)→ ViewModel → Activity → 布局 2. **在 `app/src/main/AndroidManifest.xml` 注册 Activity**(最易遗忘): ```xml ``` > `screenOrientation` **必须写 `unspecified`**,不要写死 `userLandscape`、也不要用资源引用。 > 写死横屏会让手机端「先横屏、约 1 秒后转竖屏」闪一下;资源引用则会让平板端出问题 > (详见下方「屏幕方向」小节)。 3. 在 `ARouterConstants` 注册路由(如需) 4. 标题栏统一用 ``,Activity 中 `setBackArrow("标题")` 5. **双形态布局**:需同时支持手机的页面,平板布局放 `res/layout-sw600dp/`、手机布局放 `res/layout/`,两者**同名**且变量与共用 id 一致。详见「5.5 寸手持端双形态适配规范」 ### 常见业务操作 **扫码**: ```kotlin 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`) **刷新事件**: ```kotlin // 发送(ViewModel 中,必须在协程中) viewModelScope.launch { FlowBus.with(ConstantEvent.EVENT_REFRESH).emit("refresh") } // 接收(Activity 中,必须导入 observe) FlowBus.with(ConstantEvent.EVENT_REFRESH).observe(this) { viewModel.refresh() } ``` **静态启动方法**: ```kotlin 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`): ```kotlin // 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`: ```kotlin 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`: ```kotlin // 缩略图加载(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 的请求覆盖手动加载: ```xml ``` **大图预览同理** — `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_search` 36dp + padding 2dp - `img_add` 40dp 无 padding(使用 `drawable/img_add.xml` 矢量图,`drawable-xhdpi/img_add.png` 已废弃删除) - `img_delete` 36dp + padding 4dp - `ic_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` ### 错误排查流程 1. Import 错误 → 查上方 Import 速查表 2. 资源引用错误 → 检查 drawable/color/string 是否存在 3. DataBinding 错误 → 检查 import、枚举值、View 类导入 4. suspend function 错误 → 在 `viewModelScope.launch` 中调用 5. 双形态布局问题 → 查「常见编译错误速查」中 3 条布局变体相关条目;可用 `DeviceUtil.describe()` 打日志确认当前形态 6. 仍有问题 → `./gradlew clean` 后重新构建 ### 双端验证命令 ```bash ~/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` / 公共组件后,**必须在平板上回归**,确认现有平板版行为不变。