Files
aerologic-app/CLAUDE.md
2025-11-12 10:55:32 +08:00

74 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

项目概述

AirLogistics (航空物流信息App) - Android原生应用用于管理航空物流的全流程操作包括国内外货物进出港、仓储管理、车辆调度等核心业务。

  • 包名: com.lukouguoji.aerologic
  • 当前版本: 1.7.9 (versionCode 79)
  • 开发语言: Kotlin + Java混合
  • 架构模式: MVVM + 组件化
  • 最低SDK: Android 7.0 (API 24)
  • 目标SDK: Android 10 (API 30)

构建与运行

环境准备

  1. 依赖下载问题解决:

  2. 配置IP地址:

    • 内网地址配置在 module_base/src/main/res/values/strings.xml 中的 system_url_inner
    • 地磅地址: weight_url
    • 运行时可通过SharedPreferences修改IP地址

构建命令

# 组件化开发模式切换
# 编辑 gradle.properties 中的 isBuildModule
# true: 模块可独立运行调试
# false: 模块作为library集成默认

# 构建Debug版本
./gradlew assembleDebug

# 构建Release版本已签名
./gradlew assembleRelease

# 安装到设备
./gradlew installDebug

# 清理构建
./gradlew clean

测试命令

# 运行单元测试
./gradlew test

# 运行特定模块的测试
./gradlew :module_base:test
./gradlew :app:test

# 运行UI测试
./gradlew connectedAndroidTest

核心架构

模块化结构

项目采用组件化架构,通过isBuildModule参数控制模块独立运行或作为library集成

  • app: 应用壳层,整合所有业务模块,提供主界面框架
  • module_base: 核心基础库(可独立运行),提供所有通用能力
  • module_gnc: 国内出港业务(收运、复磅、装机等)
  • module_gnj: 国内进港业务(卸机、提货、移库等)
  • module_gjc: 国际出港业务板箱组装、ULD管理等
  • module_gjj: 国际进港业务(舱单、理货、交接等)
  • module_hangban: 航班查询管理
  • module_cargo: 货物追踪查询
  • module_mit: 监装监卸管理
  • module_p: PDA专用功能
  • Printer: 蓝牙打印模块佳博SDK
  • MPChartLib: 定制图表库

MVVM架构模式

所有业务页面遵循统一的MVVM模式

Activity/Fragment (View层)
    ↓ 继承
BaseBindingActivity<ViewDataBinding, ViewModel>
    ↓ 持有
ViewModel (业务逻辑层)
    ↓ 继承
BaseViewModel / BasePageViewModel
    ↓ 调用
Repository (数据层: Retrofit API)

关键基类:

  • BaseActivity: 协程支持、Loading管理、扫码功能、键盘控制
  • BaseBindingActivity: 提供DataBinding和ViewModel自动绑定
  • BaseViewModel: 提供Loading管理、Lifecycle感知、Activity结果处理
  • BasePageViewModel: 扩展分页列表功能、PageModel集成
  • CommonAdapter + BaseViewHolder: 列表适配器统一封装

基类架构详解

BaseActivity

文件位置: module_base/src/main/java/com/lukouguoji/module_base/BaseActivity.kt

核心能力:

  • 协程支持: 实现CoroutineScope,自动管理协程生命周期
  • Loading管理: 内置LoadingDialog支持30秒超时自动关闭
  • 扫码功能: 封装ZXing扫码自动处理相机权限申请
  • 键盘控制: 点击空白区域自动隐藏软键盘
  • Activity管理: 通过ActivityCollector统一管理生命周期
  • 字体锁定: 强制字体大小不随系统设置变化
  • 屏幕适配: 集成AutoSize自动适配横屏1152dp × 720dp

关键方法:

// 显示/隐藏Loading
fun loading()
fun loadingCancel()

// 扫码功能
fun scanCode(requestCode: Int)

// 设置标题栏
open fun setBackArrow(title: String)

BaseBindingActivity

文件位置: module_base/src/main/java/com/lukouguoji/module_base/base/BaseBindingActivity.kt

设计特点:

  • 使用ViewBinding/DataBinding自动绑定视图
  • 自动创建和管理ViewModel
  • 生命周期自动绑定到lifecycleOwner
  • 简化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("页面标题")

        // 绑定ViewModel到布局
        binding.viewModel = viewModel

        // 初始化其他UI组件
        initRecyclerView()
        initListeners()
    }
}

BaseViewModel

文件位置: module_base/src/main/java/com/lukouguoji/module_base/base/BaseViewModel.kt

核心能力:

  • Loading管理: 提供showLoading()/dismissLoading()
  • Activity结果处理: onActivityResult()回调
  • 顶层Activity获取: getTopActivity()
  • 生命周期感知: 继承自AndroidX ViewModel

核心方法:

abstract class BaseViewModel : ViewModel(), ILoading {
    // Loading管理
    override fun showLoading()
    override fun dismissLoading()

    // Activity结果处理
    open fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?)

    // 获取顶层Activity
    fun getTopActivity(): Activity
}

BasePageViewModel

文件位置: module_base/src/main/java/com/lukouguoji/module_base/base/BasePageViewModel.kt

核心特性:

  • 继承自BaseViewModel
  • 集成PageModel自动处理分页逻辑
  • 实现IGetData接口,统一getData()方法
  • 实现IOnItemClickListener接口,处理列表点击

标准使用模板:

class XxxListViewModel : BasePageViewModel() {

    // LiveData定义
    val searchText = MutableLiveData<String>()
    val dataList = MutableLiveData<List<XxxBean>>()

    // 适配器配置(在布局中使用)
    val itemLayoutId = R.layout.item_xxx
    val itemViewHolder = XxxViewHolder::class.java

    // 实现数据加载
    override fun getData() {
        val requestBody = mapOf(
            "page" to pageModel.page,
            "limit" to pageModel.limit,
            "searchText" to searchText.value
        ).toRequestBody()

        launchLoadingCollect({
            NetApply.api.getXxxList(requestBody)
        }) {
            onSuccess = {
                pageModel.handleListBean(it)  // 自动处理分页数据
            }
        }
    }

    // 实现列表点击
    override fun onItemClick(position: Int, type: Int) {
        val bean = pageModel.rv!!.commonAdapter()!!.getItem(position) as XxxBean
        // 跳转详情页
        XxxDetailsActivity.start(getTopActivity(), bean.id)
    }
}

PageModel - 分页工具类

文件位置: module_base/src/main/java/com/lukouguoji/module_base/model/PageModel.kt

核心功能:

class PageModel {
    var page: Int = 1        // 当前页码
    var limit: Int = 20      // 每页数量
    var rv: RecyclerView?    // 列表引用

    // 绑定SmartRefreshLayout在Activity中调用
    fun bindSmartRefreshLayout(
        srl: SmartRefreshLayout,
        rv: RecyclerView,
        getData: IGetData,
        lifecycleOwner: LifecycleOwner
    )

    // 处理列表数据在ViewModel中调用
    fun handleListBean(listBean: BaseListBean<*>?)
}

在Activity中使用:

override fun initOnCreate(savedInstanceState: Bundle?) {
    setBackArrow("列表页面")
    binding.viewModel = viewModel

    // 绑定分页逻辑
    viewModel.pageModel.bindSmartRefreshLayout(
        binding.srl,           // SmartRefreshLayout
        binding.recyclerView,  // RecyclerView
        viewModel,             // 实现了IGetData的ViewModel
        this               // LifecycleOwner
    )

    // 绑定列表点击
    binding.recyclerView.addOnItemClickListener(viewModel)
}

CommonAdapter - 通用列表适配器

文件位置: module_base/src/main/java/com/lukouguoji/module_base/base/CommonAdapter.kt

核心方法:

class CommonAdapter(
    val context: Context,
    private val layoutId: Int,
    private val viewHolderClass: Class<out BaseViewHolder<*, out ViewDataBinding>>
) : RecyclerView.Adapter<BaseViewHolder<*, out ViewDataBinding>>()

// 数据管理
fun refresh(list: List<out Any>?)              // 刷新数据(清空后重新加载)
fun loadMore(list: List<out Any>?)             // 加载更多(追加数据)
fun addItem(item: Any?)                        // 添加单个项
fun removeItem(position: Int)                  // 删除项
fun getItem(position: Int): Any?               // 获取项

// 事件监听
fun addOnItemClickListener(listener: IOnItemClickListener)

使用示例:

// 在Activity中创建适配器
val adapter = CommonAdapter(
    context = this,
    layoutId = R.layout.item_xxx,
    viewHolderClass = XxxViewHolder::class.java
)
binding.recyclerView.adapter = adapter

// 刷新数据
adapter.refresh(dataList)

// 添加点击事件
adapter.addOnItemClickListener(viewModel)

BaseViewHolder - ViewHolder基类

文件位置: module_base/src/main/java/com/lukouguoji/module_base/base/BaseViewHolder.kt

标准实现模板:

class XxxViewHolder(view: View) :
    BaseViewHolder<XxxBean, ItemXxxBinding>(view) {

    override fun onBind(item: Any?, position: Int) {
        // 获取数据Bean
        val bean = getItemBean(item) ?: return

        // 使用DataBinding绑定数据
        binding.apply {
            this.bean = bean
            tvName.text = bean.name
            tvDate.text = bean.date

            // 设置整个条目可点击
            notifyItemClick(position, root)

            // 或设置特定按钮可点击
            notifyItemClick(position, btnEdit)
            notifyItemClick(position, btnDelete)
        }
    }
}

网络请求架构详解

ServiceCreator - Retrofit创建器

文件位置: module_base/src/main/java/com/lukouguoji/module_base/http/user/ServiceCreator.kt

核心配置:

object ServiceCreator {
    // 动态获取IP地址可在运行时修改
    var ipAddress = SharedPreferenceUtil.getString(Constant.Share.ipAddress)
        .noNull(MyApplication.context.getString(R.string.system_url_inner))

    fun createRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(ipAddress)
            .client(httpClientBuilder(SelfLoginInterceptor).build())
            .addConverterFactory(FastJsonConverterFactory())
            .build()
    }
}

拦截器功能:

  • 自动添加Authorization: Bearer {token}
  • 添加timestamp时间戳
  • 统一处理401未登录自动跳转登录页
  • 统一处理500错误并Toast提示
  • 打印请求和响应日志Debug模式

NetApply - API统一入口

文件位置: module_base/src/main/java/com/lukouguoji/module_base/http/net/NetApply.kt

object NetApply {
    private const val DEFAULT_TIMEOUT: Long = 30000L
    var api: Api by Delegates.notNull()

    // Gson实例支持日期格式化、空值处理
    val gson: Gson = GsonBuilder()
        .setDateFormat(DevFinal.TIME.yyyyMMddHHmmss_HYPHEN)
        .registerTypeAdapterFactory(NullStringAdapterFactory())
        .serializeNulls()
        .create()
}

Api接口定义

文件位置: module_base/src/main/java/com/lukouguoji/module_base/http/net/Api.kt

标准接口模式:

interface Api {
    companion object {
        var BASE_URL = ServiceCreator.ipAddress
    }

    // 通用POST请求
    @POST
    suspend fun simplePost(
        @Url url: String,
        @Body body: RequestBody = mapOf("" to "").toRequestBody()
    ): BaseResultBean<SimpleResultBean>

    // 具体业务接口示例

    // 列表查询(返回分页数据)
    @POST("DomExpCheckIn/search")
    suspend fun getGncShouYunList(@Body data: RequestBody): BaseListBean<GncShouYunBean>

    // 详情查询(返回单个对象)
    @POST("DomExpCheckIn/queryWbByNo")
    suspend fun getGncShouYunDetails(@Query("wbNo") wbNo: String): BaseResultBean<GncShouYunBean>

    // 新增/编辑/删除(返回成功标志)
    @POST("DomExpCheckIn/save")
    suspend fun saveGncShouYun(@Body data: RequestBody): BaseResultBean<SimpleResultBean>

    // 文件上传
    @Multipart
    @POST("api/upload")
    suspend fun uploadFile(@Part file: MultipartBody.Part): BaseResultBean<FileBean>
}

RequestKtx - 请求扩展函数

文件位置: module_base/src/main/java/com/lukouguoji/module_base/ktx/RequestKtx.kt

(1) launchCollect - 无Loading请求

适用场景:后台刷新、非关键操作、不需要阻塞用户操作的请求

fun <T> Any.launchCollect(
    block: suspend () -> T,
    resultBuilder: ResultBuilder<T>.() -> Unit
)

使用示例:

launchCollect({
    NetApply.api.getXxxList(params.toRequestBody())
}) {
    onSuccess = { result ->
        // 成功处理
        dataList.value = result.data
    }
    onFailed = { code, message ->
        // 失败处理默认已showToast
        Log.e("TAG", "请求失败: $message")
    }
    onComplete = {
        // 请求完成(无论成功失败)
        isRefreshing.value = false
    }
}

(2) launchLoadingCollect - 带Loading请求

适用场景:关键操作、提交表单、需要用户等待的请求

fun <T> ILoading.launchLoadingCollect(
    block: suspend () -> T,
    resultBuilder: ResultBuilder<T>.() -> Unit
)

使用示例:

launchLoadingCollect({
    NetApply.api.saveXxx(params.toRequestBody())
}) {
    onSuccess = { result ->
        showToast("保存成功")
        finish()  // 关闭当前页面
    }
    onFailed = { code, message ->
        // 失败处理已自动显示Toast和关闭Loading
    }
}

(3) toRequestBody - 数据转换

fun Any.toRequestBody(removeEmptyOrNull: Boolean = false): RequestBody

功能:

  • 将Map或Bean自动转换为JSON格式的RequestBody
  • removeEmptyOrNull = true: 移除空字符串和null值字段

使用示例:

val params = mapOf(
    "page" to 1,
    "limit" to 20,
    "waybillNo" to "",    // 空字符串
    "status" to null      // null值
)

// 不移除空值
params.toRequestBody()
// 生成: {"page":1,"limit":20,"waybillNo":"","status":null}

// 移除空值
params.toRequestBody(removeEmptyOrNull = true)
// 生成: {"page":1,"limit":20}

BaseResultBean - 统一返回格式

文件位置: module_base/src/main/java/com/lukouguoji/module_base/bean/BaseResultBean.kt

open class BaseResultBean<T> {
    var msg: String? = null     // 消息提示
    var status: String = ""     // 状态码("1"=成功,其他=失败)
    var data: T? = null         // 实际数据

    fun verifySuccess(): Boolean {
        return status == "1"    // 判断是否成功
    }
}

BaseListBean - 分页返回:

class BaseListBean<T> {
    var pages = 1               // 总页数
    var total = 0               // 总数量
    var list: ArrayList<T>? = null  // 数据列表
}

统一UI组件规范

自定义控件

1. PadSearchLayout - 搜索输入框组合控件

文件位置: module_base/src/main/java/com/lukouguoji/module_base/ui/weight/search/layout/PadSearchLayout.kt

功能: 集成多种输入类型的搜索框组件

支持的类型:

  • SearchLayoutType.INPUT - 文本输入
  • SearchLayoutType.INTEGER - 数字输入
  • SearchLayoutType.SPINNER - 下拉选择
  • SearchLayoutType.DATE - 日期选择

核心属性:

type: SearchLayoutType          // 控件类型
value: String                   // 绑定的值(支持双向绑定@={}
hint: String                    // 提示文字
list: List<KeyValue>            // 下拉选项列表
required: Boolean               // 是否必填(显示*号)
icon: Int                       // 右侧图标资源ID
enable: Boolean                 // 是否可编辑
refreshCallBack: () -> Unit     // 刷新回调
onIconClickListener: (View) -> Unit  // 图标点击回调

使用示例:

<!-- 文本输入+扫码 -->
<com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
    android:layout_width="0dp"
    android:layout_height="40dp"
    android:layout_weight="1"
    type="@{SearchLayoutType.INPUT}"
    value="@={viewModel.waybillNo}"
    hint="@{`请输入运单号`}"
    required="@{true}"
    icon="@{@mipmap/scan_code}"
    setOnIconClickListener="@{(v)-> viewModel.scanWaybill()}" />

<!-- 日期选择 -->
<com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
    android:layout_width="0dp"
    android:layout_height="40dp"
    android:layout_weight="1"
    type="@{SearchLayoutType.DATE}"
    value="@={viewModel.date}"
    hint="@{`选择日期`}"
    icon="@{@mipmap/calendar}" />

<!-- 下拉选择 -->
<com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
    android:layout_width="0dp"
    android:layout_height="40dp"
    android:layout_weight="1"
    type="@{SearchLayoutType.SPINNER}"
    list="@{viewModel.statusList}"
    value="@={viewModel.status}"
    hint="@{`选择状态`}" />

2. PadDataLayout - 数据展示/编辑组合控件

文件位置: module_base/src/main/java/com/lukouguoji/module_base/ui/weight/data/layout/PadDataLayout.kt

功能: 带标题的数据展示/编辑控件(标题+输入框/下拉/日期)

支持的类型:

  • DataLayoutType.INPUT - 文本输入
  • DataLayoutType.SPINNER - 下拉选择
  • DataLayoutType.DATE - 日期选择

核心属性:

type: DataLayoutType            // 控件类型
title: String                   // 左侧标题
titleLength: Int                // 标题长度(用于对齐,单位:汉字个数)
value: String                   // 绑定的值(支持双向绑定@={}
hint: String                    // 提示文字
list: List<KeyValue>            // 下拉选项列表
required: Boolean               // 是否必填(显示*号)
icon: Int                       // 右侧图标
enable: Boolean                 // 是否可编辑
inputHeight: Int                // 多行输入高度
maxLength: Int                  // 最大长度

使用示例:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <!-- 文本输入 -->
    <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        enable="@{viewModel.pageType != DetailsPageType.Details}"
        required="@{true}"
        title='@{"运单号:"}'
        titleLength="@{5}"
        type="@{DataLayoutType.INPUT}"
        value='@={viewModel.bean.waybillNo}'
        maxLength="@{11}" />

    <!-- 下拉选择 -->
    <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        enable="@{viewModel.pageType != DetailsPageType.Details}"
        list="@{viewModel.statusList}"
        required="@{true}"
        title='@{"状态:"}'
        titleLength="@{5}"
        type="@{DataLayoutType.SPINNER}"
        value='@={viewModel.bean.status}' />

    <!-- 日期选择 -->
    <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        enable="@{viewModel.pageType != DetailsPageType.Details}"
        title='@{"日期:"}'
        titleLength="@{5}"
        type="@{DataLayoutType.DATE}"
        value='@={viewModel.bean.date}' />
</LinearLayout>

<!-- 多行文本输入 -->
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    title='@{"备注:"}'
    titleLength="@{5}"
    type="@{DataLayoutType.INPUT}"
    value='@={viewModel.bean.remark}'
    inputHeight="@{100}" />

3. StatusView - 状态栏占位View

文件位置: module_base/src/main/java/com/lukouguoji/module_base/ui/weight/StatusView.kt

功能: 自动适配状态栏高度的占位View用于沉浸式状态栏

使用方式:

<com.lukouguoji.module_base.ui.weight.StatusView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/colorPrimary" />

通用样式定义

文本样式 (module_base/res/values/styles.xml)

<!-- 列表项标签(灰色) -->
<style name="tv_item_label">
    <item name="android:textColor">@color/weak_grey</item>
    <item name="android:layout_width">wrap_content</item>
    <item name="android:layout_height">wrap_content</item>
</style>

<!-- 列表项值(蓝色) -->
<style name="tv_item_value">
    <item name="android:textColor">@color/colorPrimary</item>
    <item name="android:textSize">15sp</item>
    <item name="android:layout_marginLeft">10dp</item>
    <item name="android:singleLine">true</item>
</style>

<!-- 列表项操作按钮(白色文字) -->
<style name="tv_item_action">
    <item name="android:textColor">@color/white</item>
    <item name="android:textSize">16sp</item>
    <item name="android:gravity">center</item>
    <item name="android:paddingStart">10dp</item>
    <item name="android:paddingEnd">10dp</item>
</style>

<!-- 详情页标签 -->
<style name="tv_manifest_details_label">
    <item name="android:layout_width">90dp</item>
    <item name="android:textColor">#999999</item>
    <item name="android:textSize">16sp</item>
</style>

<!-- 详情页值 -->
<style name="tv_manifest_details_value">
    <item name="android:layout_width">match_parent</item>
    <item name="android:layout_height">40dp</item>
    <item name="android:background">@color/white</item>
    <item name="android:gravity">center_vertical</item>
    <item name="android:textColor">#333333</item>
    <item name="android:textSize">16sp</item>
</style>

按钮样式

<!-- 底部按钮(标准大小) -->
<style name="tv_bottom_btn">
    <item name="android:layout_width">100dp</item>
    <item name="android:layout_height">40dp</item>
    <item name="android:background">@drawable/bg_btn_bottom</item>
    <item name="android:textColor">@color/white</item>
    <item name="android:textSize">18sp</item>
    <item name="android:gravity">center</item>
</style>

<!-- 底部按钮(大尺寸) -->
<style name="tv_bottom_btn_lg">
    <item name="android:layout_width">150dp</item>
    <item name="android:layout_height">50dp</item>
    <item name="android:background">@drawable/bg_btn_bottom</item>
    <item name="android:textColor">@color/white</item>
    <item name="android:textSize">18sp</item>
</style>

<!-- 信息项按钮 -->
<style name="info_item_button">
    <item name="android:layout_width">120dp</item>
    <item name="android:layout_height">60dp</item>
    <item name="android:background">@drawable/submit_shape</item>
    <item name="android:textColor">@color/white</item>
    <item name="android:gravity">center</item>
</style>

布局样式

<!-- 搜索行布局 -->
<style name="ll_search">
    <item name="android:layout_width">0dp</item>
    <item name="android:layout_height">match_parent</item>
    <item name="android:layout_weight">1</item>
    <item name="android:background">@drawable/bg_search_row</item>
    <item name="android:orientation">horizontal</item>
</style>

<!-- 信息项容器 -->
<style name="info_item_content_parent">
    <item name="android:layout_width">0dp</item>
    <item name="android:layout_weight">12</item>
    <item name="android:background">@drawable/bg_data_layout</item>
</style>

<!-- 必填标记(红色*号) -->
<style name="info_item_must">
    <item name="android:layout_width">30dp</item>
    <item name="android:textColor">@color/red</item>
    <item name="android:gravity">center|start</item>
</style>

颜色规范 (module_base/res/values/colors.xml)

<!-- 主色调 -->
<color name="colorPrimary">#FF1C8CF5</color>    <!-- 蓝色主色 -->
<color name="app_them">#0A80FC</color>

<!-- 文本颜色 -->
<color name="textValue">#1E1E1E</color>         <!-- 深黑 -->
<color name="text_normal">#333333</color>        <!-- 正常文本 -->
<color name="text_gray">#666666</color>          <!-- 灰色文本 -->
<color name="text_gray_l">#999999</color>        <!-- 浅灰文本 -->
<color name="weak_grey">#FF999999</color>        <!-- 弱灰色 -->
<color name="text_blue">#3CB5F3</color>          <!-- 蓝色文本 -->

<!-- 基础颜色 -->
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
<color name="red">#d9001b</color>

<!-- 背景颜色 -->
<color name="list_bg">#FFEDEDED</color>                  <!-- 列表背景 -->
<color name="backgroud_gray">#FFEDEDED</color>           <!-- 页面背景灰色 -->
<color name="home_area_bg">#FFF6FBFF</color>             <!-- 首页区域背景 -->
<color name="disable_grey">#FFEDEDED</color>             <!-- 禁用灰色 -->
<color name="data_layout_disable_grey">#F1F1F1</color>   <!-- 数据布局禁用色 -->
<color name="color_bottom_layout">#5c6890</color>        <!-- 底部布局颜色 -->

Drawable背景规范

输入框背景

<!-- bg_search_layout.xml - 搜索框背景(选择器) -->
<selector>
    <item android:drawable="@drawable/bg_search_layout_s" android:state_enabled="true" />
    <item android:drawable="@drawable/bg_search_layout_n" android:state_enabled="false" />
</selector>

<!-- bg_data_layout.xml - 数据布局背景(选择器) -->
<selector>
    <item android:drawable="@drawable/bg_data_layout_s" android:state_enabled="true" />
    <item android:drawable="@drawable/bg_data_layout_n" android:state_enabled="false" />
</selector>

<!-- 启用状态:白色 + 8dp圆角 -->
<!-- 禁用状态:#E0E0E0灰色 + 8dp圆角 -->

使用说明:

  • bg_search_layout: 搜索区域输入框专用8dp圆角
  • bg_data_layout: 数据展示区域输入框专用4dp圆角
  • bg_input: 通用输入框背景(白色+灰色边框+8dp圆角

按钮背景

<!-- bg_btn_bottom.xml - 底部按钮背景(选择器) -->
<selector>
    <item android:drawable="@drawable/bg_primary_radius_4" android:state_enabled="true" />
    <item android:drawable="@drawable/bg_gray_radius_4" android:state_enabled="false" />
</selector>

<!-- 启用:蓝色主色 + 4dp圆角 -->
<!-- 禁用:灰色 + 4dp圆角 -->

常用按钮背景:

  • @drawable/bg_btn_bottom - 底部按钮(蓝色/灰色选择器)
  • @drawable/submit_shape - 提交按钮(蓝色+5dp圆角
  • @drawable/bg_red_radius_5 - 红色按钮5dp圆角
  • @drawable/bg_primary_radius_4 - 主色按钮4dp圆角

通用形状背景

  • @drawable/bg_white_radius_8 - 白色圆角背景8dp
  • @drawable/bg_white_circle - 白色圆形背景
  • @drawable/bg_dialog_round - 对话框圆角背景
  • @drawable/bg_confirm_dialog - 确认对话框背景

通用布局组件

标题栏 (title_tool_bar.xml)

使用方式:

<include layout="@layout/title_tool_bar" />

在Activity中设置标题

setBackArrow("页面标题")

DataBinding适配器

项目中提供了丰富的BindingAdapter简化DataBinding使用。

图片加载 (BindingAdapter.kt)

<ImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    loadImage="@{imageUrl}"
    loadError="@{@mipmap/default_image}"
    loadPlaceholder="@{@mipmap/loading}"
    loadCircle="@{true}"
    loadRadius="@{8}" />

支持的属性:

  • loadImage: 图片地址URL或本地路径
  • loadError: 错误图片
  • loadPlaceholder: 占位图
  • loadCircle: 是否圆形true/false
  • loadRadius: 圆角dp
  • loadWidth/loadHeight: 指定宽高

View可见性 (ViewAdapter.kt)

<!-- 支持Boolean -->
<TextView
    visible="@{viewModel.showText}"
    android:text="文本内容" />

<!-- 支持Int0隐藏非0显示 -->
<TextView
    visible="@{viewModel.count}"
    android:text="@{String.valueOf(viewModel.count)}" />

<!-- 支持String空隐藏非空显示 -->
<TextView
    visible="@{viewModel.message}"
    android:text="@{viewModel.message}" />

TextView文本对齐 (TextViewAdapter.kt)

<!-- 自动对齐标题 -->
<LinearLayout android:orientation="vertical">
    <TextView
        android:text="运单号"
        completeSpace="@{5}" />  <!-- 按5个汉字宽度对齐 -->

    <TextView
        android:text="状态"
        completeSpace="@{5}" />

    <TextView
        android:text="日期"
        completeSpace="@{5}" />
</LinearLayout>

Spinner下拉框 (SpinnerAdapter.kt)

<Spinner
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    items="@{viewModel.statusList}"
    hint="@{`请选择状态`}"
    onSelected="@{(position)-> viewModel.onStatusSelected(position)}" />

Shape动态背景 (ShapeBindingAdapter.kt)

<!-- 纯色背景 + 圆角 + 边框 -->
<View
    android:layout_width="match_parent"
    android:layout_height="50dp"
    shape_radius="@{8}"
    shape_bg_color="@{`#FF0000`}"
    shape_border_width="@{1}"
    shape_border_color="@{`#000000`}" />

<!-- 渐变背景 -->
<View
    android:layout_width="match_parent"
    android:layout_height="50dp"
    shape_gradient_start_color="@{`#FF0000`}"
    shape_gradient_end_color="@{`#00FF00`}"
    shape_gradient_angle="@{0}" />

<!-- 虚线边框 -->
<View
    android:layout_width="match_parent"
    android:layout_height="1dp"
    shape_border_width="@{1}"
    shape_border_color="@{`#999999`}"
    shape_dash_width="@{4}"
    shape_dash_gap="@{2}" />

EditText扩展 (EditTextKtx.kt)

<!-- 自动转大写 -->
<EditText
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    setTextAllCaps="@{true}"
    android:hint="输入大写字母" />

<!-- 运单号输入模式限制11位数字 -->
<EditText
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    setInputWaybill="@{true}"
    android:hint="请输入11位运单号" />

Kotlin扩展函数

Toast扩展 (ToastKtx.kt)

// 显示Toast默认长时间
showToast("操作成功")

// 短时间Toast
showToast("提示信息", isShort = true)

Dialog扩展 (DialogKtx.kt)

// 确认对话框
showConfirmDialog(
    message = "确定要删除吗?",
    title = "提示",
    confirmText = "确定",
    cancelText = "取消"
) {
    // 确认操作
    deleteItem()
}

字符串处理 (CommonKtx.kt)

// 空处理
val text = nullableString.noNull("默认值")
val text2 = nullableString.noNull()  // 空字符串

// 验证非空
if (waybillNo.verifyNullOrEmpty("请输入运单号")) {
    return  // 验证失败会自动Toast
}

布尔转换

// Boolean -> Int
val intValue = booleanValue.toInt()  // true=1, false=0

// String -> Boolean
val bool = "1".toBoolean()  // "1"=true, 其他=false

// Int -> Boolean
val bool = 1.toBoolean()  // 1=true, 其他=false

日期格式化

// 格式化日期
val dateStr = Date().formatDate()  // "2025-11-12"
val dateStr2 = Date().formatDate("yyyy/MM/dd")  // "2025/11/12"

// 格式化日期时间
val dateTimeStr = Date().formatDateTime()  // "2025-11-12 14:30:00"

JSON处理

// 对象转JSON
val json = userBean.toJson()
val jsonFormatted = userBean.toJson(format = true)  // 格式化输出

// 对象转Map
val map = userBean.toMap()

// Map去除空值
val cleanMap = map.trim(deep = true)

权限申请

// 申请单个权限
permission(Manifest.permission.CAMERA) {
    // 获取权限后操作
    openCamera()
}

// 申请多个权限
permission(
    Manifest.permission.CAMERA,
    Manifest.permission.WRITE_EXTERNAL_STORAGE
) {
    // 所有权限获取后操作
    takePicture()
}

// 处理拒绝情况
permission(
    Manifest.permission.CAMERA,
    onDenied = {
        showToast("需要相机权限才能扫码")
    },
    onGranted = {
        openCamera()
    }
)

路由系统

ARouter路由注册

路由常量定义: module_base/src/main/java/com/lukouguoji/module_base/router/ARouterConstants.kt

object ARouterConstants {
    // 国内出港
    const val ACTIVITY_URL_GNC_SHOUYUN_LIST = "/gnc/GncShouYunListActivity"
    const val ACTIVITY_URL_GNC_STASH_DETAILS = "/gnc/GncStashDetailsActivity"

    // 国际进港
    const val ACTIVITY_URL_GJJ_TALLY_LIST = "/gjj/GjjTallyListActivity"
    const val ACTIVITY_URL_GJJ_GOODS_LIST = "/gjj/GjjGoodsListActivity"

    // 通用功能
    const val ACTIVITY_URL_SCAN = "/common/ScanActivity"
}

使用方式

// 1. 在Activity上添加注解
@Route(path = ARouterConstants.ACTIVITY_URL_GNC_SHOUYUN_LIST)
class GncShouYunListActivity : BaseBindingActivity<...>() {
    // ...
}

// 2. 跳转页面
ARouter.getInstance()
    .build(ARouterConstants.ACTIVITY_URL_GNC_SHOUYUN_LIST)
    .withString("waybillNo", "12345678901")
    .withInt("type", 1)
    .navigation()

// 3. 接收参数
@Autowired(name = "waybillNo")
@JvmField
var waybillNo: String? = null

@Autowired(name = "type")
@JvmField
var type: Int = 0

override fun initOnCreate(savedInstanceState: Bundle?) {
    ARouter.getInstance().inject(this)  // 注入参数
    // 使用参数
    Log.d("TAG", "waybillNo: $waybillNo, type: $type")
}

事件通信

FlowBus事件总线

文件位置: module_base/src/main/java/com/lukouguoji/module_base/impl/FlowBus.kt

// 定义事件常量在ConstantEvent.kt中
object ConstantEvent {
    const val EVENT_REFRESH_LIST = "event_refresh_list"
    const val EVENT_UPDATE_STATUS = "event_update_status"
}

// 发送事件
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_LIST).emit("refresh")

// 接收事件在ViewModel中
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_LIST).observe(this) { data ->
    // 处理事件
    loadData()
}

// 粘性事件(先发送后接收也能收到)
FlowBus.withSticky<UserBean>("user_info").emit(userBean)
FlowBus.withSticky<UserBean>("user_info").observe(this) { user ->
    // 处理用户信息
}

开发规范

代码组织规范

目录结构标准

module_xxx/
└── src/main/java/com/lukouguoji/xxx/
    ├── page/                       # 页面目录(推荐新模式)
    │   └── feature/                # 功能模块
    │       ├── list/               # 列表页
    │       │   ├── XxxListActivity.kt
    │       │   ├── XxxListViewModel.kt
    │       │   └── XxxListViewHolder.kt
    │       ├── details/            # 详情页
    │       │   ├── XxxDetailsActivity.kt
    │       │   └── XxxDetailsViewModel.kt
    │       └── add/                # 新增/编辑页
    │           ├── XxxAddActivity.kt
    │           └── XxxAddViewModel.kt
    ├── activity/                   # Activity目录旧模式
    ├── viewModel/                  # ViewModel目录旧模式
    └── holder/                     # ViewHolder目录

命名规范

Activity命名:

  • 列表页: XxxListActivity
  • 详情页: XxxDetailsActivity
  • 新增页: XxxAddActivity
  • 编辑页: XxxEditActivity

ViewModel命名:

  • 列表: XxxListViewModel
  • 详情: XxxDetailsViewModel
  • 新增: XxxAddViewModel

ViewHolder命名:

  • 格式: XxxViewHolderXxxListViewHolder

Layout命名:

  • Activity: activity_xxx_list.xml
  • Fragment: fragment_xxx.xml
  • Item: item_xxx.xml

Bean定义规范

文件位置: module_base/src/main/java/com/lukouguoji/module_base/bean/

// 使用Kotlin data class
data class XxxBean(
    var id: String = "",
    var name: String = "",
    var date: String = "",
    var status: String = ""
)

// 如果需要DataBinding双向绑定使用BaseObservable
class XxxBean : BaseObservable() {

    @Bindable
    var name: String = ""
        set(value) {
            field = value
            notifyPropertyChanged(BR.name)
        }

    @Bindable
    var weight: String = ""
        set(value) {
            field = value
            notifyPropertyChanged(BR.weight)
        }

    // 使用ObservableBoolean/ObservableInt等
    val checked = ObservableBoolean(false)
    val count = ObservableInt(0)

    // 计算属性
    val displayName: String
        get() = "【$name】"
}

实际业务开发示例

列表页完整示例

Activity层

@Route(path = ARouterConstants.ACTIVITY_URL_GNC_SHOUYUN_LIST)
class GncShouYunListActivity :
    BaseBindingActivity<ActivityGncShouyunListBinding, GncShouYunListViewModel>() {

    override fun layoutId() = R.layout.activity_gnc_shouyun_list

    override fun viewModelClass() = GncShouYunListViewModel::class.java

    override fun initOnCreate(savedInstanceState: Bundle?) {
        setBackArrow("国内出港收运记录")

        // 绑定ViewModel
        binding.viewModel = viewModel

        // 绑定分页逻辑
        viewModel.pageModel.bindSmartRefreshLayout(
            binding.srl,
            binding.recyclerView,
            viewModel,
            this
        )

        // 绑定列表点击
        binding.recyclerView.addOnItemClickListener(viewModel)

        // 初始加载
        viewModel.refresh()
    }
}

ViewModel层

class GncShouYunListViewModel : BasePageViewModel() {

    // LiveData定义
    val date = MutableLiveData(DateUtils.getCurrentTime().formatDate())
    val dest = MutableLiveData("")
    val waybillNo = MutableLiveData("")
    val count = MutableLiveData("0")

    // 适配器配置(在布局中使用)
    val itemLayoutId = R.layout.item_gnc_shouyun
    val itemViewHolder = GncShouYunListViewHolder::class.java

    // 数据加载
    override fun getData() {
        val requestBody = mapOf(
            "page" to pageModel.page,
            "limit" to pageModel.limit,
            "opDate" to date.value,
            "dest" to dest.value,
            "wbNo" to waybillNo.value
        ).toRequestBody()

        launchLoadingCollect({
            NetApply.api.getGncShouYunList(requestBody)
        }) {
            onSuccess = {
                pageModel.handleListBean(it)
                count.value = it.total.toString()
            }
        }
    }

    // 条目点击
    override fun onItemClick(position: Int, type: Int) {
        val bean = pageModel.rv!!.commonAdapter()!!.getItem(position) as GncShouYunBean
        GncStashDetailsActivity.start(getTopActivity(), bean.whId)
    }

    // 搜索
    fun search() {
        refresh()
    }

    // 扫码
    fun scanWaybill() {
        ScanModel.startScan(getTopActivity(), Constant.RequestCode.WAYBILL)
    }

    // 处理扫码结果
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (resultCode == Activity.RESULT_OK && data != null) {
            when (requestCode) {
                Constant.RequestCode.WAYBILL -> {
                    waybillNo.value = data.getStringExtra(Constant.Result.CODED_CONTENT)
                    search()
                }
            }
        }
    }
}

ViewHolder层

class GncShouYunListViewHolder(view: View) :
    BaseViewHolder<GncShouYunBean, ItemGncShouyunBinding>(view) {

    override fun onBind(item: Any?, position: Int) {
        val bean = getItemBean(item) ?: return

        // DataBinding绑定数据
        binding.bean = bean

        // 整个条目可点击
        notifyItemClick(position, binding.shouyunItem)

        // 特定控件点击
        binding.ivIcon.setOnClickListener {
            bean.checked.set(!bean.checked.get())
        }
    }
}

Layout层

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <import type="com.lukouguoji.module_base.ui.weight.search.layout.SearchLayoutType" />
        <variable name="viewModel" type="com.lukouguoji.gnc.page.shouyun.list.GncShouYunListViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/backgroud_gray"
        android:orientation="vertical">

        <!-- 标题栏 -->
        <include layout="@layout/title_tool_bar" />

        <!-- 搜索区域 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="60dp"
            android:background="@color/colorPrimary"
            android:orientation="horizontal"
            android:padding="10dp">

            <!-- 日期 -->
            <com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                hint="@{`选择日期`}"
                icon="@{@mipmap/calendar}"
                type="@{SearchLayoutType.DATE}"
                value="@={viewModel.date}" />

            <Space
                android:layout_width="10dp"
                android:layout_height="match_parent" />

            <!-- 运单号 -->
            <com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                hint="@{`请输入运单号`}"
                icon="@{@mipmap/scan_code}"
                setOnIconClickListener="@{(v)-> viewModel.scanWaybill()}"
                type="@{SearchLayoutType.INPUT}"
                value="@={viewModel.waybillNo}" />

            <Space
                android:layout_width="10dp"
                android:layout_height="match_parent" />

            <!-- 搜索按钮 -->
            <TextView
                android:layout_width="80dp"
                android:layout_height="match_parent"
                android:background="@drawable/submit_shape"
                android:gravity="center"
                android:onClick="@{()-> viewModel.search()}"
                android:text="搜索"
                android:textColor="@color/white"
                android:textSize="18sp" />
        </LinearLayout>

        <!-- 统计信息 -->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="40dp"
            android:background="@color/white"
            android:gravity="center_vertical"
            android:paddingStart="15dp"
            android:text="@{`共 ` + viewModel.count + ` 条记录`}"
            android:textColor="@color/text_gray"
            android:textSize="14sp" />

        <!-- 列表 -->
        <com.scwang.smart.refresh.layout.SmartRefreshLayout
            android:id="@+id/srl"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/recyclerView"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:overScrollMode="never"
                app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
        </com.scwang.smart.refresh.layout.SmartRefreshLayout>
    </LinearLayout>
</layout>

详情页完整示例

Activity层

@Route(path = ARouterConstants.ACTIVITY_URL_GNC_STASH_DETAILS)
class GncStashDetailsActivity :
    BaseBindingActivity<ActivityGncStashDetailsBinding, GncStashDetailsViewModel>() {

    override fun layoutId() = R.layout.activity_gnc_stash_details

    override fun viewModelClass() = GncStashDetailsViewModel::class.java

    override fun initOnCreate(savedInstanceState: Bundle?) {
        setBackArrow("国内出港运单详情")

        // 初始化ViewModel传递参数
        viewModel.initOnCreated(intent)

        // 绑定ViewModel
        binding.viewModel = viewModel
    }

    companion object {
        /**
         * 静态启动方法(推荐方式)
         */
        @JvmStatic
        fun start(context: Context, whId: String) {
            val starter = Intent(context, GncStashDetailsActivity::class.java)
                .putExtra(Constant.Key.ID, whId)
            context.startActivity(starter)
        }
    }
}

ViewModel层

class GncStashDetailsViewModel : BaseViewModel() {

    var id = ""
    val dataBean = MutableLiveData<GncStashBean>()

    fun initOnCreated(intent: Intent) {
        id = intent.getStringExtra(Constant.Key.ID) ?: ""
        getData()
    }

    private fun getData() {
        launchLoadingCollect({
            NetApply.api.getGncStashDetails(id)
        }) {
            onSuccess = {
                dataBean.value = it.data ?: GncStashBean()
            }
        }
    }

    // 打印标签
    fun printLabel() {
        dataBean.value?.let { bean ->
            PrinterModel.printWaybillLabel(bean)
        }
    }
}

Layout层

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <variable name="viewModel" type="com.lukouguoji.gnc.page.stash.details.GncStashDetailsViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/backgroud_gray"
        android:orientation="vertical">

        <include layout="@layout/title_tool_bar" />

        <ScrollView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:padding="15dp">

                <!-- 基本信息 -->
                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="基本信息"
                    android:textColor="@color/text_normal"
                    android:textSize="16sp"
                    android:textStyle="bold" />

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    android:background="@drawable/bg_white_radius_8"
                    android:orientation="vertical"
                    android:padding="15dp">

                    <!-- 运单号 -->
                    <LinearLayout
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:orientation="horizontal">

                        <TextView
                            style="@style/tv_manifest_details_label"
                            android:text="运单号:" />

                        <TextView
                            style="@style/tv_manifest_details_value"
                            android:text="@{viewModel.dataBean.waybillNo}" />
                    </LinearLayout>

                    <!-- 分割线 -->
                    <View
                        android:layout_width="match_parent"
                        android:layout_height="1dp"
                        android:background="@color/list_bg" />

                    <!-- 件数 -->
                    <LinearLayout
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:orientation="horizontal">

                        <TextView
                            style="@style/tv_manifest_details_label"
                            android:text="件数:" />

                        <TextView
                            style="@style/tv_manifest_details_value"
                            android:text="@{viewModel.dataBean.pieces}" />
                    </LinearLayout>

                    <!-- 重量 -->
                    <LinearLayout
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:orientation="horizontal">

                        <TextView
                            style="@style/tv_manifest_details_label"
                            android:text="重量:" />

                        <TextView
                            style="@style/tv_manifest_details_value"
                            android:text="@{viewModel.dataBean.weight + `kg`}" />
                    </LinearLayout>
                </LinearLayout>
            </LinearLayout>
        </ScrollView>

        <!-- 底部按钮 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="60dp"
            android:background="@color/white"
            android:gravity="center"
            android:orientation="horizontal">

            <TextView
                style="@style/tv_bottom_btn_lg"
                android:onClick="@{()-> viewModel.printLabel()}"
                android:text="打印标签" />
        </LinearLayout>
    </LinearLayout>
</layout>

新增/编辑页完整示例

Activity层

@Route(path = ARouterConstants.ACTIVITY_URL_CAR_ADD)
class CarAddActivity :
    BaseBindingActivity<ActivityCarAddBinding, CarAddViewModel>() {

    override fun layoutId() = R.layout.activity_car_add

    override fun viewModelClass() = CarAddViewModel::class.java

    override fun initOnCreate(savedInstanceState: Bundle?) {
        viewModel.initOnCreated(intent)

        // 根据页面类型设置标题
        when (viewModel.pageType) {
            DetailsPageType.Add -> setBackArrow("新增车辆")
            DetailsPageType.Edit -> setBackArrow("编辑车辆")
            DetailsPageType.Details -> setBackArrow("车辆详情")
        }

        binding.viewModel = viewModel
    }

    companion object {
        @JvmStatic
        fun startForAdd(context: Context) {
            val starter = Intent(context, CarAddActivity::class.java)
                .putExtra(Constant.Key.PAGE_TYPE, DetailsPageType.Add.name)
            context.startActivity(starter)
        }

        @JvmStatic
        fun startForEdit(context: Context, carId: String) {
            val starter = Intent(context, CarAddActivity::class.java)
                .putExtra(Constant.Key.PAGE_TYPE, DetailsPageType.Edit.name)
                .putExtra(Constant.Key.ID, carId)
            context.startActivity(starter)
        }

        @JvmStatic
        fun startForDetails(context: Context, carId: String) {
            val starter = Intent(context, CarAddActivity::class.java)
                .putExtra(Constant.Key.PAGE_TYPE, DetailsPageType.Details.name)
                .putExtra(Constant.Key.ID, carId)
            context.startActivity(starter)
        }
    }
}

ViewModel层

class CarAddViewModel : BaseViewModel() {

    var pageType: DetailsPageType = DetailsPageType.Add
    var carId = ""

    val carBean = MutableLiveData(CarBean())
    val statusList = MutableLiveData<List<KeyValue>>()

    fun initOnCreated(intent: Intent) {
        // 获取页面类型
        pageType = DetailsPageType.valueOf(
            intent.getStringExtra(Constant.Key.PAGE_TYPE) ?: DetailsPageType.Add.name
        )

        // 加载下拉列表
        loadStatusList()

        // 如果是编辑或详情,加载数据
        if (pageType != DetailsPageType.Add) {
            carId = intent.getStringExtra(Constant.Key.ID) ?: ""
            loadData()
        }
    }

    private fun loadStatusList() {
        statusList.value = listOf(
            KeyValue("1", "正常"),
            KeyValue("2", "维修中"),
            KeyValue("3", "停用")
        )
    }

    private fun loadData() {
        launchLoadingCollect({
            NetApply.api.getCarDetails(carId)
        }) {
            onSuccess = {
                carBean.value = it.data ?: CarBean()
            }
        }
    }

    fun submit() {
        val bean = carBean.value ?: return

        // 验证
        if (bean.carId.verifyNullOrEmpty("请输入车辆编号")) return
        if (bean.status.verifyNullOrEmpty("请选择状态")) return

        // 提交
        launchLoadingCollect({
            val params = mapOf(
                "id" to carId,
                "carId" to bean.carId,
                "status" to bean.status,
                "remark" to bean.remark
            ).toRequestBody(removeEmptyOrNull = true)

            NetApply.api.saveCar(params)
        }) {
            onSuccess = {
                showToast(if (pageType == DetailsPageType.Add) "新增成功" else "编辑成功")

                // 发送刷新事件
                FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_CAR_LIST).emit("refresh")

                // 关闭页面
                getTopActivity().finish()
            }
        }
    }
}

Layout层

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <import type="com.lukouguoji.module_base.ui.weight.data.layout.DataLayoutType" />
        <import type="com.lukouguoji.module_base.constant.DetailsPageType" />
        <variable name="viewModel" type="com.lukouguoji.car.page.add.CarAddViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/backgroud_gray"
        android:orientation="vertical">

        <include layout="@layout/title_tool_bar" />

        <ScrollView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:padding="15dp">

                <!-- 第一行 -->
                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="horizontal">

                    <!-- 车辆编号 -->
                    <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
                        android:layout_width="0dp"
                        android:layout_height="wrap_content"
                        android:layout_weight="1"
                        enable="@{viewModel.pageType != DetailsPageType.Details}"
                        required="@{viewModel.pageType != DetailsPageType.Details}"
                        title='@{"车辆编号:"}'
                        titleLength="@{5}"
                        type="@{DataLayoutType.INPUT}"
                        value='@={viewModel.carBean.carId}' />

                    <Space
                        android:layout_width="10dp"
                        android:layout_height="match_parent" />

                    <!-- 状态 -->
                    <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
                        android:layout_width="0dp"
                        android:layout_height="wrap_content"
                        android:layout_weight="1"
                        enable="@{viewModel.pageType != DetailsPageType.Details}"
                        list="@{viewModel.statusList}"
                        required="@{viewModel.pageType != DetailsPageType.Details}"
                        title='@{"状态:"}'
                        titleLength="@{5}"
                        type="@{DataLayoutType.SPINNER}"
                        value='@={viewModel.carBean.status}' />
                </LinearLayout>

                <!-- 备注 -->
                <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    enable="@{viewModel.pageType != DetailsPageType.Details}"
                    inputHeight="@{100}"
                    title='@{"备注:"}'
                    titleLength="@{5}"
                    type="@{DataLayoutType.INPUT}"
                    value='@={viewModel.carBean.remark}' />
            </LinearLayout>
        </ScrollView>

        <!-- 底部按钮(详情页不显示) -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="60dp"
            android:background="@color/white"
            android:gravity="center"
            android:orientation="horizontal"
            visible="@{viewModel.pageType != DetailsPageType.Details}">

            <TextView
                style="@style/tv_bottom_btn_lg"
                android:onClick="@{()-> viewModel.submit()}"
                android:text="@{viewModel.pageType == DetailsPageType.Add ? `提交` : `保存`}" />
        </LinearLayout>
    </LinearLayout>
</layout>

开发检查清单

列表页开发清单7步

步骤1: 创建Bean

// 位置: module_base/src/main/java/com/lukouguoji/module_base/bean/
data class XxxBean(
    var id: String = "",
    var name: String = "",
    var date: String = "",
    var status: String = ""
)

步骤2: 在Api中添加接口

// 位置: module_base/src/main/java/com/lukouguoji/module_base/http/net/Api.kt
@POST("api/xxx/list")
suspend fun getXxxList(@Body data: RequestBody): BaseListBean<XxxBean>

步骤3: 创建ViewHolder

// 位置: module_xxx/src/main/java/.../holder/
class XxxViewHolder(view: View) :
    BaseViewHolder<XxxBean, ItemXxxBinding>(view) {

    override fun onBind(item: Any?, position: Int) {
        val bean = getItemBean(item) ?: return
        binding.bean = bean
        notifyItemClick(position, binding.root)
    }
}

步骤4: 创建ViewModel

// 继承BasePageViewModel
class XxxListViewModel : BasePageViewModel() {

    val searchText = MutableLiveData<String>()
    val itemLayoutId = R.layout.item_xxx
    val itemViewHolder = XxxViewHolder::class.java

    override fun getData() {
        val requestBody = mapOf(
            "page" to pageModel.page,
            "limit" to pageModel.limit,
            "searchText" to searchText.value
        ).toRequestBody()

        launchLoadingCollect({
            NetApply.api.getXxxList(requestBody)
        }) {
            onSuccess = {
                pageModel.handleListBean(it)
            }
        }
    }

    override fun onItemClick(position: Int, type: Int) {
        val bean = pageModel.rv!!.commonAdapter()!!.getItem(position) as XxxBean
        // 跳转详情页
    }
}

步骤5: 创建Activity

@Route(path = ARouterConstants.ACTIVITY_URL_XXX_LIST)
class XxxListActivity :
    BaseBindingActivity<ActivityXxxListBinding, XxxListViewModel>() {

    override fun layoutId() = R.layout.activity_xxx_list
    override fun viewModelClass() = XxxListViewModel::class.java

    override fun initOnCreate(savedInstanceState: Bundle?) {
        setBackArrow("列表页面")
        binding.viewModel = viewModel

        viewModel.pageModel.bindSmartRefreshLayout(
            binding.srl,
            binding.recyclerView,
            viewModel,
            this
        )

        binding.recyclerView.addOnItemClickListener(viewModel)
    }
}

步骤6: 创建Layout

<!-- activity_xxx_list.xml -->
<layout>
    <data>
        <variable name="viewModel" type="...XxxListViewModel" />
    </data>

    <LinearLayout>
        <include layout="@layout/title_tool_bar" />

        <!-- 搜索区域 -->
        <com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout ... />

        <!-- 列表 -->
        <com.scwang.smart.refresh.layout.SmartRefreshLayout android:id="@+id/srl">
            <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" />
        </com.scwang.smart.refresh.layout.SmartRefreshLayout>
    </LinearLayout>
</layout>

<!-- item_xxx.xml -->
<layout>
    <data>
        <variable name="bean" type="...XxxBean" />
    </data>

    <LinearLayout>
        <!-- 列表项内容 -->
    </LinearLayout>
</layout>

步骤7: 注册路由

// 位置: module_base/.../router/ARouterConstants.kt
const val ACTIVITY_URL_XXX_LIST = "/xxx/XxxListActivity"

详情页开发清单4步

步骤1: 在Api中添加接口

@POST("api/xxx/details")
suspend fun getXxxDetails(@Query("id") id: String): BaseResultBean<XxxBean>

步骤2: 创建ViewModel

class XxxDetailsViewModel : BaseViewModel() {

    var id = ""
    val dataBean = MutableLiveData<XxxBean>()

    fun initOnCreated(intent: Intent) {
        id = intent.getStringExtra(Constant.Key.ID) ?: ""
        getData()
    }

    private fun getData() {
        launchLoadingCollect({
            NetApply.api.getXxxDetails(id)
        }) {
            onSuccess = {
                dataBean.value = it.data ?: XxxBean()
            }
        }
    }
}

步骤3: 创建Activity含静态start方法

@Route(path = ARouterConstants.ACTIVITY_URL_XXX_DETAILS)
class XxxDetailsActivity :
    BaseBindingActivity<ActivityXxxDetailsBinding, XxxDetailsViewModel>() {

    override fun layoutId() = R.layout.activity_xxx_details
    override fun viewModelClass() = XxxDetailsViewModel::class.java

    override fun initOnCreate(savedInstanceState: Bundle?) {
        setBackArrow("详情页面")
        viewModel.initOnCreated(intent)
        binding.viewModel = viewModel
    }

    companion object {
        @JvmStatic
        fun start(context: Context, id: String) {
            val starter = Intent(context, XxxDetailsActivity::class.java)
                .putExtra(Constant.Key.ID, id)
            context.startActivity(starter)
        }
    }
}

步骤4: 创建Layout

<layout>
    <data>
        <variable name="viewModel" type="...XxxDetailsViewModel" />
    </data>

    <LinearLayout>
        <include layout="@layout/title_tool_bar" />

        <ScrollView>
            <!-- 详情内容 -->
            <LinearLayout>
                <TextView android:text="@{viewModel.dataBean.name}" />
                <!-- 更多字段... -->
            </LinearLayout>
        </ScrollView>
    </LinearLayout>
</layout>

新增/编辑页开发清单5步

步骤1: 在Api中添加接口

@POST("api/xxx/save")
suspend fun saveXxx(@Body data: RequestBody): BaseResultBean<SimpleResultBean>

@POST("api/xxx/details")
suspend fun getXxxDetails(@Query("id") id: String): BaseResultBean<XxxBean>

步骤2: 创建ViewModel

class XxxAddViewModel : BaseViewModel() {

    var pageType: DetailsPageType = DetailsPageType.Add
    var id = ""

    val dataBean = MutableLiveData(XxxBean())
    val optionList = MutableLiveData<List<KeyValue>>()

    fun initOnCreated(intent: Intent) {
        pageType = DetailsPageType.valueOf(
            intent.getStringExtra(Constant.Key.PAGE_TYPE) ?: DetailsPageType.Add.name
        )

        loadOptions()

        if (pageType != DetailsPageType.Add) {
            id = intent.getStringExtra(Constant.Key.ID) ?: ""
            loadData()
        }
    }

    private fun loadData() {
        launchLoadingCollect({
            NetApply.api.getXxxDetails(id)
        }) {
            onSuccess = {
                dataBean.value = it.data ?: XxxBean()
            }
        }
    }

    fun submit() {
        val bean = dataBean.value ?: return

        // 验证
        if (bean.name.verifyNullOrEmpty("请输入名称")) return

        // 提交
        launchLoadingCollect({
            val params = mapOf(
                "id" to id,
                "name" to bean.name,
                // 其他字段...
            ).toRequestBody(removeEmptyOrNull = true)

            NetApply.api.saveXxx(params)
        }) {
            onSuccess = {
                showToast(if (pageType == DetailsPageType.Add) "新增成功" else "保存成功")
                FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_LIST).emit("refresh")
                getTopActivity().finish()
            }
        }
    }
}

步骤3: 创建Activity含多个静态start方法

@Route(path = ARouterConstants.ACTIVITY_URL_XXX_ADD)
class XxxAddActivity :
    BaseBindingActivity<ActivityXxxAddBinding, XxxAddViewModel>() {

    override fun layoutId() = R.layout.activity_xxx_add
    override fun viewModelClass() = XxxAddViewModel::class.java

    override fun initOnCreate(savedInstanceState: Bundle?) {
        viewModel.initOnCreated(intent)

        when (viewModel.pageType) {
            DetailsPageType.Add -> setBackArrow("新增")
            DetailsPageType.Edit -> setBackArrow("编辑")
            DetailsPageType.Details -> setBackArrow("详情")
        }

        binding.viewModel = viewModel
    }

    companion object {
        @JvmStatic
        fun startForAdd(context: Context) {
            val starter = Intent(context, XxxAddActivity::class.java)
                .putExtra(Constant.Key.PAGE_TYPE, DetailsPageType.Add.name)
            context.startActivity(starter)
        }

        @JvmStatic
        fun startForEdit(context: Context, id: String) {
            val starter = Intent(context, XxxAddActivity::class.java)
                .putExtra(Constant.Key.PAGE_TYPE, DetailsPageType.Edit.name)
                .putExtra(Constant.Key.ID, id)
            context.startActivity(starter)
        }

        @JvmStatic
        fun startForDetails(context: Context, id: String) {
            val starter = Intent(context, XxxAddActivity::class.java)
                .putExtra(Constant.Key.PAGE_TYPE, DetailsPageType.Details.name)
                .putExtra(Constant.Key.ID, id)
            context.startActivity(starter)
        }
    }
}

步骤4: 创建Layout

<layout>
    <data>
        <import type="com.lukouguoji.module_base.ui.weight.data.layout.DataLayoutType" />
        <import type="com.lukouguoji.module_base.constant.DetailsPageType" />
        <variable name="viewModel" type="...XxxAddViewModel" />
    </data>

    <LinearLayout>
        <include layout="@layout/title_tool_bar" />

        <ScrollView>
            <LinearLayout>
                <!-- 表单字段 -->
                <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
                    enable="@{viewModel.pageType != DetailsPageType.Details}"
                    required="@{true}"
                    title='@{"名称:"}'
                    titleLength="@{5}"
                    type="@{DataLayoutType.INPUT}"
                    value='@={viewModel.dataBean.name}' />

                <!-- 更多字段... -->
            </LinearLayout>
        </ScrollView>

        <!-- 底部按钮(详情页不显示) -->
        <LinearLayout visible="@{viewModel.pageType != DetailsPageType.Details}">
            <TextView
                style="@style/tv_bottom_btn_lg"
                android:onClick="@{()-> viewModel.submit()}"
                android:text="@{viewModel.pageType == DetailsPageType.Add ? `提交` : `保存`}" />
        </LinearLayout>
    </LinearLayout>
</layout>

步骤5: 注册路由并发送刷新事件

// 注册路由
const val ACTIVITY_URL_XXX_ADD = "/xxx/XxxAddActivity"

// 在列表页接收刷新事件
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_LIST).observe(this) {
    viewModel.refresh()
}

常见业务场景

扫码后查询

// 在ViewModel中
fun scanWaybill() {
    ScanModel.startScan(getTopActivity(), Constant.RequestCode.WAYBILL)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode == Constant.RequestCode.WAYBILL && resultCode == Activity.RESULT_OK) {
        val code = data?.getStringExtra(Constant.Result.CODED_CONTENT)
        waybillNo.value = code
        search()  // 自动搜索
    }
}

打印标签

// 绑定打印服务
bindService(Intent(this, PrinterService::class.java), serviceConnection, BIND_AUTO_CREATE)

// 打印
val printData = DataForSendToPrinter()
printData.addText("运单号: ${waybillNo}")
printData.addText("重量: ${weight}kg")
printData.addBarcode(waybillNo, 2, 100)
printerService?.sendPrintData(printData)

图片上传

// 选择图片
PictureSelector.create(this)
    .openGallery(SelectMimeType.ofImage())
    .setMaxSelectNum(1)
    .setImageEngine(GlideEngine.createGlideEngine())
    .forResult { result ->
        val path = result[0].realPath
        uploadImage(path)
    }

// 上传接口
private fun uploadImage(path: String) {
    val file = File(path)
    val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
    val part = MultipartBody.Part.createFormData("file", file.name, requestFile)

    launchLoadingCollect({
        NetApply.api.uploadFile(part)
    }) {
        onSuccess = { result ->
            imageUrl.value = result.data?.url
            showToast("上传成功")
        }
    }
}

关键技术点

屏幕适配

  • 横屏设计尺寸: 1152dp × 720dp主要场景
  • 竖屏设计尺寸: 720dp × 1280dp
  • 强制横屏: 所有Activity在AndroidManifest中配置 android:screenOrientation="userLandscape"
  • 适配库: AutoSize 1.2.+

蓝牙打印

核心类:

  • Printer 模块:独立的打印服务
  • PrinterService: 后台打印服务
  • PrinterConfig: 打印机配置管理
  • 使用佳博SDK 2.0.4

打印流程:

  1. 启动PrinterService
  2. 扫描/绑定蓝牙打印机
  3. 构建打印数据(DataForSendToPrinter)
  4. 发送打印任务

扫码功能

  • 使用 ZXing 2.2.9 库
  • 扫描条形码/二维码
  • 主要用于运单号、ULD编号、车辆编号、板箱编号

权限管理

使用 AndPermission 2.0.2 或扩展函数:

// 方式1: 使用扩展函数(推荐)
permission(Manifest.permission.CAMERA) {
    openCamera()
}

// 方式2: 使用AndPermission
AndPermission.with(this)
    .runtime()
    .permission(Permission.CAMERA)
    .onGranted { }
    .onDenied { }
    .start()

图片选择

使用 PictureSelector v3.11.2 + Glide 4.15.1

PictureSelector.create(this)
    .openGallery(SelectMimeType.ofImage())
    .setMaxSelectNum(9)
    .setImageEngine(GlideEngine.createGlideEngine())
    .forResult { result ->
        // 处理选择结果
    }

重要配置文件

签名配置

  • KeyStore: key.jks (项目根目录)
  • 密码: storePassword/keyPassword均为 123321
  • 别名: key

网络配置

  • 超时时间: 30秒连接/读取/写入)
  • 认证方式: Bearer Token通过拦截器自动添加
  • Token存储: SharedPreferences (key: Constant.Share.token)
  • 网络安全配置: res/xml/network_security_config.xml (支持HTTP)

数据持久化

  • SharedPreferences: IP地址、Token、用户信息、角色
  • 关键常量: 定义在 Constant.ktConstantEvent.kt

Git分支管理

  • 当前开发分支: feature/hefei
  • 主分支: develop用于PR
  • 提交前: 确保代码通过编译,无明显错误

技术栈速查

  • 协程: kotlinx-coroutines 1.6.0
  • 网络: Retrofit 2.6.1 + OkHttp 3.12.12
  • JSON: FastJSON 1.2.73 + Gson 2.10.1
  • 路由: ARouter 1.5.2
  • 下拉刷新: SmartRefreshLayout 2.0.3
  • 图表: MPAndroidChart (定制版)
  • 弹窗: XPopup 2.9.19
  • 图片: Glide 4.15.1 + PictureSelector v3.11.2
  • 扫码: ZXing 2.2.9
  • 权限: AndPermission 2.0.2
  • 打印: 佳博SDK 2.0.4
  • 日志: Timber 5.0.1
  • 事件: EventBus 3.1.1 + FlowBus

总结

本开发指南涵盖了Android航空物流App的完整开发流程包括

  1. 基类架构: 统一的MVVM架构减少样板代码
  2. 网络请求: 协程+Flow的现代化异步方案
  3. UI组件: 高度封装的输入、展示控件,保证界面一致性
  4. DataBinding: 全面的适配器支持,简化视图绑定
  5. 扩展函数: 丰富的Kotlin扩展提高开发效率
  6. 开发清单: 详细的步骤指引,确保不遗漏关键环节

开发原则:

  • 优先使用项目现有的基类和封装
  • 充分利用PadDataLayout和PadSearchLayout组件
  • 遵循统一的命名和目录组织规范
  • 使用DataBinding简化代码
  • 利用扩展函数处理通用逻辑
  • 不重复造轮子,保持架构一致性