Files
aerologic-app/CLAUDE.md
YANG JIANKUAN 49f11ef8e6 fix: 进港移库归属更正——删除误建IntImpMove,重新适配到国内进港DomImpMove
设计稿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>
2026-08-03 12:17:10 +08:00

75 KiB
Raw Blame History

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 基类: BaseActivityBaseBindingActivityDataBindingBaseViewModelBasePageViewModel(分页列表)
  • 适配器: CommonAdapter + BaseViewHolder 统一列表封装
  • 路由: ARouter 1.5.2 | 事件: FlowBusFlow+ EventBus 3.1.1
  • 网络: Retrofit 2.6.1 + OkHttp 3.12.12 + Coroutines
    • launchCollect:无 Loading 后台请求
    • launchLoadingCollect:带 Loading 请求
    • toRequestBodyMap/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.ktGjcBoxWeighingViewModel.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>

        <!-- 第二行 KVmarginTop=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,尺寸 30x30dplayout_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"运单号:"=4row2"特码:"=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.xmlitem_gjc_box_weighing.xml


类型 2多选列表 + 批量操作页

代表: IntExpOutHandoverActivity / GjcAssembleAllocateActivity 结构: 类型 1 基础上 + 全选按钮 + ObservableBoolean 选中状态 + 批量操作

与类型 1 的区别:

  1. Bean 增加 ObservableBoolean:
class XxxBean {
    val checked: ObservableBoolean = ObservableBoolean(false)
    var isSelected: Boolean
        get() = checked.get()
        set(value) = checked.set(value)
}
  1. 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() }
    }
}
  1. Activity 观察全选图标:
viewModel.isAllChecked.observe(this) { binding.checkIcon.alpha = if (it) 1.0f else 0.5f }
  1. Item 布局图片切换:
<ImageView android:id="@+id/iv_icon"
    loadImage="@{bean.checked.get() ? @drawable/img_plane_s : @drawable/img_plane}" />
  1. ViewHolder 中处理点击:
binding.ivIcon.setOnClickListener {
    bean.checked.set(!bean.checked.get())
    binding.executePendingBindings()
}

参考文件: module_gjc/.../IntExpOutHandoverActivity.ktIntExpOutHandoverViewModel.kt


类型 3嵌套多选列表页

代表: IntExpStorageUseActivity 结构: 主列表含子列表(展开/收起)+ 主子联动全选 + Dialog 操作

与类型 2 的区别:

  1. Activity 暴露给布局(用于调用 Dialog 方法):
binding.activity = this  // XML 中可调用 activity.showXxxDialog()
  1. 联动全选(主+子列表):
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) }
    }
}
  1. 全局展开/收起:
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()
}
  1. 子列表项 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 15spKey 和 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 高度 1pxbackground="@color/c999999"
  • 子列表项 paddingpaddingHorizontal="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" />

loadImageandroid:src 均须使用 _style 系列,禁止使用 _gray 系列(参见上方复选框样式表)

参考文件: module_gjc/.../IntExpStorageUseActivity.ktIntExpStorageUseViewModel.kt


类型 4Tab 详情页

代表: 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.ktGjcQueryDetailsViewModel.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.ktGjcQueryEditViewModel.kt


类型 6添加表单页含输入回调

代表: GjcBoxWeighingAddActivity 结构: 类型 5 基础上 + setRefreshCallBack 输入完成回调 + 实时计算 + 扫码自动填充

与类型 5 的区别:

  1. 输入完成回调(关键特性):
<!-- 使用方法引用,不能用 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)  // 输入完成后自动查询
    }
}
  1. 级联查询(航班日期+航班号同时有值时查询):
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) }
    }
}
  1. 实时计算:
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"
    }
}
  1. 输入限制:
// Activity 中设置
binding.carIdInput.et.setUpperCaseAlphanumericFilter()
  1. 重置功能:
fun resetClick() {
    dataBean.value = XxxBean()
    carId.value = ""; uldNo.value = ""; flightNo.value = ""
    lastQueriedCarId = ""; lastQueriedUld = ""; lastQueriedFlight = ""
}

参考文件: module_gjc/.../GjcBoxWeighingAddActivity.ktGjcBoxWeighingAddViewModel.kt


自定义 Dialog 开发模式

基于 BaseDialogModelXPopup 封装),支持 5 种弹窗类型。

⚠️ 强制规则:所有二次确认弹框必须使用 ConfirmDialogModelcom.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.ktmodule_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 关键规则

  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 必须导入: <import type="android.view.View" />
  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

<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 中引用的 idsrl/rv/checkIcon/iv_icon)两个变体都要保留
单侧独有的 id 自动变 @Nullable 只在一个变体存在的 idBinding 基类中会是可空字段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 时,即使 BaseActivitysuper.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/ 的竖屏值。后果是平板端:

  1. 先竖屏、随后被 BaseActivity 纠正成横屏 —— 反而把闪屏问题搬到了平板上
  2. AutoSize 在竖屏配置下选了平板竖屏基准 720dpdensity=1280/720≈1.78,正常应为 1280/1152≈1.11 → 交互几次后整个 UI 放大约 1.6 倍

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

⚠️ AutoSize 设计基准必须区分手机(易漏且后果严重)

项目使用 AndroidAutoSizeMyApplication.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,圆角 12dpmarginHorizontal=16dpmarginTop=12dppadding=14dp
卡片头行 圆形勾选框 20dpradiobtn_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.xmlitem 布局同理)
  2. 新建手机布局:在 res/layout/ 下创建同名文件,保持相同 <variable> 与共用控件 id
  3. Kotlin 侧不动layoutId()itemLayoutId、ViewHolder 全部保持原样
  4. 纯 UI 交互Tab 切换、弹层显隐)写在 Activity不污染 ViewModel状态过滤优先复用 ViewModel 既有字段
  5. 手机布局顶部 <include layout="@layout/title_tool_bar" /> 不变(变体自动切换)
  6. 弹框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 居中样式
  7. 回到手机端首页加菜单入口(最易遗忘,见下节)——不加的话页面在手机上根本进不去

手机端首页菜单入口(适配完必须加,否则没入口)

手机端首页是 module_pPDAEnterActivity(手持机首页),底部四个 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如移库的 GjcYiKuListActivityIntExpMoveActivityPad 菜单实际跳的才是要适配的那个
  • 图标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 顶部搜索行:搜索框 + 扫码 + 圆形筛选按钮 hintvalue(双向)、showScanshowFilterupperCasesetOnScanClickListenersetOnFilterClickListenersetRefreshCallBack
PhoneStatusTab 状态 Tab 条(文字 + 角标,选中态下划线) tabs(List<KeyValue>)、counts(List<String>)、value(双向)、setOnTabChanged
PhoneDataLayout 表单/筛选项,PhoneDataLayoutType = INPUT/DATE/SPINNER titlehinttypevalue(双向)、listrequiredenablesetRefreshCallBack
PhoneFilterPanel 底部筛选弹层(遮罩 + 面板 + 重置/确认) titlepanelVisiblesetOnResetClicksetOnConfirmClicksetOnDismissClick
PhoneBottomBar 底部操作条:全选 + 已选统计 + 主按钮 allCheckedcountTextactionTextsetOnAllCheckClicksetOnActionClick
PhoneStatBox 卡片内 2~4 列统计框(未传 label 的列自动隐藏) label1..4value1..4
PhoneKvItem 详情页字段项(灰标签 + 数值),tag=true 转高亮标签(占位符 "-"/"--" 不套底) titlevaluetagtagColor("orange" 默认/"green")
PhoneFormRow 行式表单项(左标签+右值+分隔线),PhoneFormRowType = INPUT/SELECT(内置底部选择面板)/DATE/TEXT(只读右对齐,空值 "--") titlehinttypevalue(双向)、listrequiredenablenumericsetRefreshCallBack

PhoneBottomBar 追加 actionDanger 属性true 时主按钮转浅红底红字 + 垃圾桶图标ULD 管理批量删除)。 新增资源:bg_phone_btn_dangerbg_phone_fab(圆形浮动 + 按钮)、bg_phone_tag_red_light(浅红标签)、 ic_phone_trashic_phone_edit、色值 phone_danger_bg/text。 出港计重追加:bg_phone_btn_weak(浅蓝弱按钮,卡片内提前运抵)、bg_phone_card_top_accent (卡片顶部主色描边条)、ic_phone_clipboardFAB 剪贴板)、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 + UldEditActivityapp 模块首个适配页;一套编辑布局覆盖 新增/修改/详情三态;批量删除为前端循环调用单条接口;状态三态/来源等 5 项后端缺口见问题清单 #7~#11
    • 「出港计重」全链路 4+1 页:GjcWeighingListActivity(待计重列表,三格统计 + 卡片单票提前运抵 + FAB 进记录)、GjcWeighingStartActivity(开始计重,托盘数量/自重按设计稿手输)、 GjcWeighingRecordListActivity(计重记录)、GjcWeighingRecordDetailsActivity(计重明细, isEditMode 复用为手机全屏「修改记录」覆层)+ 手机专属新页 GjcWeighingWbDetailActivity(运单详情)。 搜索分流规则实测含字母→fno、11 位数字→wbNo、其余→likeNolikeNo 按 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 transientJava 序列化会跳过),反序列化后为 null直接 .checked.set(...) 会崩溃——接收方 initOnCreated 必须对列表 .map { it.copy() } 补上。 2026-07-30 review 修正:状态重置弹框改手机底部弹层(弹框双变体首例,见改造步骤第 6 条)、 主列表/回执页补空态、分单浮层补「回执」、分单统计加分母、Tab 角标初始 0
    • 「提取出库」IntImpPickUpDLVActivitymodule_gjj类型2 多选批量页 + 手机专属详情新页 IntImpPickUpDetailActivityIntent 传 Serializable bean 零请求)。待出库/已出库 Tab 绑 文档定义入参 outState0/1抓包证实携带效果待内网 #29+ 角标双 pageQueryTotal 双形态卡片按 isPickedUpdlvTime 非空)切换;手机进入时清空平板默认「当天缴费日期」查全量; 缺口 #28无 fdate 入参、出参无车牌(提货车辆)/目的港/业务类型。 ⚠️ 踩坑:checkAllClick 全选后底部「已选 N 项」不更新——批量勾选不走 FlowBus 单卡事件, 必须在 checkAllClick 里同步 selectedCountText
    • 「进港查询」IntImpQueryActivitymodule_gjj06 出港查询的进港镜像:三格统计 总票数/已入库/已出库为已加载数据客户端计数(pageQueryTotal 无分项出参,缺口 #30 卡片状态前端推断:dlvTime 非空=已出库(绿)/inDate 非空=已入库(橙)/兜底未入库(灰) 筛选弹层 9 项入参全部经 api-doc 核对真实存在(分页参数名 page/limit,品名参数名 goods);详情页与运单追踪复用 08/06 已适配页零新增;平板侧滑筛选面板代码已判空守卫。 ⚠️ 踩坑:代理人绑定不能写 bean.agentName ?? bean.agentCode——转换器把 JSON null 归一成 空串,?? 只判 null 不判空串,须用 Bean 计算属性 agentDisplay06 出港查询同步修正))
    • 「进港移库」GnjMoveStashListActivityapp 模块,2026-08-03 归属更正后重做:设计稿 11 实际归属国内进港移库 DomImpMove(后端 8 接口齐全、Pad 首页「国内进港→进港移库」既有页面), 最初误判为国际进港并按 IntExpMove 约定预接了不存在的 IntImpMove 模块(原缺口 #31该套 module_gjj 前端实现已整体删除。现行实现主列表双形态Pad 布局移 layout-sw600dp 零改动), 待移库/已移库 Tab 走 search/searchMoved 双端点searchMoved 出参无移库标识,前端按 端点给 bean 打 isMovedTab 角标 = 两端点各调一次 limit=1 取分页 totalinitPhoneExtras 开关,仅手机发起);手机专属新页 GnjMoveStashDetailActivityIntent 传 bean 零请求,三卡片: 基本信息/移库信息(仅已移库)/异常照片)+ GnjMoveStashPhotoActivity(进入先调 detail 回填 已有照片——searchMoved 出参无 pic 三字段 #34提交走 modify 最小体 {mawbId,remark,picNumber,pic,originalPic},与平板移交编辑页同链路);批量移库复用平板 moveStashClickmovefid+ids。手机首页入口在国内 TabGnViewModel,权限串 GnjYiKu=AppDomImpMove,需补通用 route 跳转逻辑,图标从 module_gnj 复制); GnjMoveStashListActivity 补 @Route /app/GnjMoveStashListActivity。 2026-08-03 内网真实数据双端验证通过;筛选 A/BwbNo/awbType 生效, fdate/fno/spCode 被后端静默忽略(#32opId/opDate 恒 null#33
    • 「进港仓库」IntImpStorageUseActivitymodule_gjj07 出港仓库的进港镜像;首页菜单实际跳转页, 旧版 GjjWareHouseActivity 路由已注释弃用。差异:卡片/筛选用始发站fdep 入参)替代目的站; 搜索框「运单号/航班号」分流含字母→fno、其余→wbNo抓包证实筛选 5 项无入参(缺口 #25 比出港多 2 项);连带首次适配进港详情 IntImpQueryDetailsActivity + 3 Fragment报文区 3 项: 原始舱单/理货报告/海关放行,件重区直取 inPc/inWeight 无需求和detail keys 以平板绑定为准 + dep/dest1/dest/unNumber/opDate 按出港推断待实测 #27修改库位手机弹层「原库位」只列未出库 库位(沿用平板校验);前端接口前缀是 IntImpStorage 不是 IntImpStorageUseapi-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+ GjcQueryDetailsActivity3 Tab 复用 ViewPager2+Fragment手机变体 PhoneKvItem 网格;入库件数/重量= warehouseList 求和; 锁定状态/计费重量后端无字段,缺口 #22+ 「运单追踪」LogDetailActivityapp 模块公共页 双变体首例:手机竖排时间线由 Activity buildVerticalSteps 动态构建,平板横向步骤条不动; 手机标题按形态切「运单追踪」)。平板侧滑筛选面板代码已判空守卫(手机变体无 filter_panel includeBinding 字段为 @Nullable
  • 设计稿 11 个入口01~11已于 2026-07-31 全部适配完成
  • 待办
    • PhoneSearchBar 接入 AutoQuery 联想(AutoQueryManager 目前只对接了 Pad 系组件)
    • 进港移库筛选三项(航班日期/航班号/特码)待后端实装 fdate/fno/spCode 入参(缺口 #32、 移库人/移库时间待后端回填 opId/opDate 并补姓名出参(缺口 #33后自动生效
    • 出库交接 Tab 角标恒 0IntExpOutHandover/pageQueryTotal 出参恒 0后端缺陷问题清单 #6 后端修复后角标自动恢复(handoverState 过滤本身已于 2026-07-29 实机验证通过)生效
  • 📋 接口对接:适配中发现的后端接口缺口统一记录在 documents/后端接口对接问题清单.xlsx (按模块/页面/接口定义/接口地址索引,与后端对接的正式文档,每页适配完必须维护); 接口入参/出参一律以 api-doc MCP 为准核对,流程见 .claude/skills/phone-adapt/references/api-doc.md——后端对不认识的参数静默忽略 猜参数名会得出「后端不支持」的误判(出库交接 hoStatehandoverState 已踩过)

调试便利:直接拉起页面

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 里临时注入几条样例 Beanbinding.rv.commonAdapter()?.refresh(list))截图比对,验证后务必删除

参考文件module_base/.../util/DeviceUtil.ktmodule_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
observeFlowBus 扩展) 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 包下)

launchCollectlaunchLoadingCollectshowToasttoRequestBodyverifyNullOrEmptynoNullformatDatesetUpperCaseAlphanumericFilter

工具类

正确路径
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/dispatchRestoreInstanceStatedispatchFreezeSelfOnly/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 双兜底(如 agentDisplayXML 绑计算属性(进港查询/出港查询代理人已踩过)

编辑表单下拉框SPINNER回填规范

编辑页面DetailsPageType.Modify下拉框需要根据已有数据自动选中对应项。必须使用 DictUtilscheckedValue 参数,禁止依赖组件自动匹配 value。

原理

DictUtilshandleCallBack 会将 checkedValue 匹配的 KeyValue 置于列表首位。PadDataLayoutNew 的 SPINNER 默认显示列表第 0 项,因此匹配项自动成为选中项,无需额外设置 selectedIndex。

标准做法(参考 GjjManifestDetailsViewModelGjjManifestAddViewModel

  1. 字典加载必须在编辑数据加载之后(不能放在 init 中),确保 checkedValue 可用
  2. 编辑模式传入 checkedValue,新增模式传 null
  3. 编辑模式不预置空 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
  • 依赖 PadDataLayoutNewvalue 属性自动匹配列表Spinner adapter 重建时 onItemSelected 回调会覆盖已有值)
  • 编辑模式下在列表前添加 KeyValue("", "")(会干扰 checkedValue 置顶排序)

开发检查清单

新页面开发必做

  1. 创建 Bean如需→ API 接口 → ViewHolder列表页→ ViewModel → Activity → 布局
  2. 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 秒后转竖屏」闪一下;资源引用则会让平板端出问题 (详见下方「屏幕方向」小节)。

  1. ARouterConstants 注册路由(如需)
  2. 标题栏统一用 <include layout="@layout/title_tool_bar" />Activity 中 setBackArrow("标题")
  3. 双形态布局:需同时支持手机的页面,平板布局放 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(注意是 newNameurl

刷新事件:

// 发送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,而非同名旧版文件。同一业务有多个实现时,以首页菜单入口链路为准。


图片上传与展示规范

图片上传三字段规范

上传图片后提交表单时,必须同时传 picoriginalPicpicNumber 三个字段,缺一不可。

UploadUtil.upload() 返回值(注意:与字面意思相反:

  • data?.newName原图文件名(较大)
  • data?.zipFileName缩略图/压缩图文件名(较小)

提交时字段映射(参考事故签证 AccidentVisaDetailsViewModelIntImpAccidentVisaEditViewModel:

// 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) }  // 原图

常见错误:

  • 只传 imagesoriginalPic 单个字段 — 接口不认或数据不完整
  • 只取 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_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_8colorPrimarytext_normaltext_graycolor_bottom_layout
  • 双形态适配:逻辑与 UI 严格分离 — 手机版只重写布局,业务逻辑一律复用现有 ViewModel / ViewHolder纯 UI 交互Tab 切换、弹层显隐)写在 Activity不下沉到 ViewModel
  • 禁止用不同文件名切换布局 — 手机/平板布局必须同名 + 资源限定符,否则 ClassCastException详见「5.5 寸手持端双形态适配规范」)
  • 手机版资源统一使用 phone_* 颜色与 bg_phone_* drawable禁止直接写死色值

环境配置

  • 服务器: module_base/.../res/values/strings.xmlsystem_url_inner / weight_url
  • 签名: key.jks(根目录),密码 123321,别名 key
  • 模块独立运行: gradle.propertiesisBuildModule=true

错误排查流程

  1. Import 错误 → 查上方 Import 速查表
  2. 资源引用错误 → 检查 drawable/color/string 是否存在
  3. DataBinding 错误 → 检查 import、枚举值、View 类导入
  4. suspend function 错误 → 在 viewModelScope.launch 中调用
  5. 双形态布局问题 → 查「常见编译错误速查」中 3 条布局变体相关条目;可用 DeviceUtil.describe() 打日志确认当前形态
  6. 仍有问题 → ./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 / 公共组件后,必须在平板上回归,确认现有平板版行为不变。