86 KiB
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)
构建与运行
环境准备
-
依赖下载问题解决:
- 下载gradle-7.3.3-bin.zip: https://pan.baidu.com/s/18wsuGRlNxjMYbxLhBH9yeg (提取码: 1029)
- 打开 Settings -> Build, Execution, Deployment > Build Tools > Gradle
- 将下载的文件解压后替换到 "Gradle user home" 目录中
-
配置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="文本内容" />
<!-- 支持Int(0隐藏,非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命名:
- 格式:
XxxViewHolder或XxxListViewHolder
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
打印流程:
- 启动PrinterService
- 扫描/绑定蓝牙打印机
- 构建打印数据(DataForSendToPrinter)
- 发送打印任务
扫码功能
- 使用 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.kt和ConstantEvent.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的完整开发流程,包括:
- 基类架构: 统一的MVVM架构,减少样板代码
- 网络请求: 协程+Flow的现代化异步方案
- UI组件: 高度封装的输入、展示控件,保证界面一致性
- DataBinding: 全面的适配器支持,简化视图绑定
- 扩展函数: 丰富的Kotlin扩展,提高开发效率
- 开发清单: 详细的步骤指引,确保不遗漏关键环节
开发原则:
- ✅ 优先使用项目现有的基类和封装
- ✅ 充分利用PadDataLayout和PadSearchLayout组件
- ✅ 遵循统一的命名和目录组织规范
- ✅ 使用DataBinding简化代码
- ✅ 利用扩展函数处理通用逻辑
- ✅ 不重复造轮子,保持架构一致性
常见编译错误及解决方案
1. DataBinding错误:Cannot resolve type 'DetailsPageType'
错误信息:
ERROR: Cannot resolve type 'DetailsPageType' file://app/src/main/res/layout/activity_xxx.xml Line:XX
错误原因: 在XML布局文件中import的包名错误。
错误示例:
<import type="com.lukouguoji.module_base.constant.DetailsPageType" />
正确写法:
<import type="com.lukouguoji.module_base.common.DetailsPageType" />
注意: DetailsPageType位于common包,不是constant包!
2. DataBinding错误:Could not find accessor DataLayoutType.INTEGER
错误信息:
ERROR: Could not find accessor com.lukouguoji.module_base.ui.weight.data.layout.DataLayoutType.INTEGER
错误原因:
DataLayoutType枚举中不存在INTEGER类型。
错误示例:
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
type="@{DataLayoutType.INTEGER}"
value='@={viewModel.bean.count}' />
正确写法:
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
type="@{DataLayoutType.INPUT}"
value='@={viewModel.bean.count}' />
可用类型:
DataLayoutType.INPUT- 文本输入(可输入数字)DataLayoutType.SPINNER- 下拉选择DataLayoutType.DATE- 日期选择
3. Kotlin编译错误:Unresolved reference: PAGE_TYPE
错误信息:
e: Unresolved reference: PAGE_TYPE
错误原因:
Constant.Key对象中缺少PAGE_TYPE常量。
解决方案:
在module_base/src/main/java/com/lukouguoji/module_base/common/Constant.kt中添加:
object Key {
// ... 其他常量
// ID
const val ID = "id"
// 页面类型
const val PAGE_TYPE = "pageType"
// ... 其他常量
}
使用示例:
val starter = Intent(context, XxxActivity::class.java)
.putExtra(Constant.Key.PAGE_TYPE, DetailsPageType.Add.name)
.putExtra(Constant.Key.ID, id)
4. Kotlin编译错误:Unresolved reference: Edit
错误信息:
e: Unresolved reference: Edit
错误原因:
DetailsPageType枚举中不存在Edit值。
错误示例:
when (viewModel.pageType) {
DetailsPageType.Add -> setBackArrow("新增")
DetailsPageType.Edit -> setBackArrow("编辑") // ❌ 错误
DetailsPageType.Details -> setBackArrow("详情")
}
正确写法:
when (viewModel.pageType) {
DetailsPageType.Add -> setBackArrow("新增")
DetailsPageType.Modify -> setBackArrow("编辑") // ✅ 正确
DetailsPageType.Details -> setBackArrow("详情")
}
DetailsPageType枚举值:
enum class DetailsPageType(val title: String) {
Add("新增"), // 新增页面
Modify("编辑"), // 编辑页面(注意:不是Edit!)
Details("详情") // 详情页面
}
5. Kotlin编译错误:Unresolved reference: IOnItemClickListener
错误信息:
e: Unresolved reference: IOnItemClickListener
错误原因:
import的包名错误,IOnItemClickListener在interfaces包,不是impl包。
错误示例:
import com.lukouguoji.module_base.impl.IOnItemClickListener // ❌ 错误
正确写法:
import com.lukouguoji.module_base.interfaces.IOnItemClickListener // ✅ 正确
使用场景:
class XxxViewModel : BaseViewModel(), IOnItemClickListener {
override fun onItemClick(position: Int, type: Int) {
// 处理点击事件
}
}
6. FlowBus使用错误
错误A:Unresolved reference: observe
错误信息:
e: Unresolved reference: observe
错误原因:
缺少observe扩展函数的import。
解决方案:
import com.lukouguoji.module_base.impl.FlowBus
import com.lukouguoji.module_base.impl.observe // ✅ 添加这一行
// 在Activity中使用
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_LIST).observe(this) {
viewModel.refresh()
}
错误B:Suspend function 'emit' should be called only from a coroutine
错误信息:
e: Suspend function 'emit' should be called only from a coroutine or another suspend function
错误原因:
emit()是suspend函数,需要在协程中调用。
错误示例:
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_LIST).emit("refresh") // ❌ 错误
正确写法:
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
// 在ViewModel中
viewModelScope.launch {
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH_LIST).emit("refresh") // ✅ 正确
}
7. 图片上传字段错误
错误信息:
e: Unresolved reference: url
错误原因:
UploadBean返回的字段名是newName,不是url。
UploadBean结构:
class UploadBean {
var newName: String = "" // ✅ 正确字段名
var zipFileName: String = ""
}
错误示例:
val result = UploadUtil.upload(filePath)
if (result.verifySuccess()) {
val imageUrl = result.data?.url ?: "" // ❌ 错误:没有url字段
}
正确写法:
val result = UploadUtil.upload(filePath)
if (result.verifySuccess()) {
val imageUrl = result.data?.newName ?: "" // ✅ 正确:使用newName
}
完整上传示例:
launchLoadingCollect({
val uploadedUrls = mutableListOf<String>()
imageList.forEach { fileBean ->
if (fileBean.path.startsWith("http")) {
// 已上传的图片,直接使用URL
uploadedUrls.add(fileBean.path)
} else {
// 本地图片,需要上传
val result = UploadUtil.upload(fileBean.path)
if (result.verifySuccess()) {
uploadedUrls.add(result.data?.newName ?: "") // 使用newName
}
}
}
// 提交时将图片URL列表用逗号拼接
val params = mapOf(
"images" to uploadedUrls.joinToString(",")
).toRequestBody()
NetApply.api.saveData(params)
}) {
onSuccess = {
showToast("保存成功")
}
}
8. RecyclerView DataBinding items属性问题
问题现象:
在DataBinding中使用items属性绑定数据会导致编译错误。
错误示例:
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvImages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
items="@{viewModel.imageList}" ❌ 这个属性会导致编译错误
itemLayoutId="@{viewModel.imageItemLayoutId}"
viewHolder="@{viewModel.imageItemViewHolder}" />
正确做法:
移除items属性,在Activity中手动更新adapter。
XML布局:
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvImages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
itemLayoutId="@{viewModel.imageItemLayoutId}"
viewHolder="@{viewModel.imageItemViewHolder}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="3" />
Activity代码:
import com.lukouguoji.module_base.ktx.commonAdapter
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("页面标题")
binding.viewModel = viewModel
// 监听数据变化并手动更新adapter
viewModel.imageList.observe(this) { images ->
binding.rvImages.commonAdapter()?.refresh(images)
}
}
9. pageType在DataBinding中的正确使用
问题:
如果pageType声明为普通变量,DataBinding无法正确绑定。
错误示例:
class XxxViewModel : BaseViewModel() {
var pageType: DetailsPageType = DetailsPageType.Add // ❌ 普通变量
}
正确写法:
class XxxViewModel : BaseViewModel() {
val pageType = MutableLiveData(DetailsPageType.Add) // ✅ 使用LiveData
}
ViewModel中访问:
fun initOnCreated(intent: Intent) {
pageType.value = DetailsPageType.valueOf(
intent.getStringExtra(Constant.Key.PAGE_TYPE) ?: DetailsPageType.Add.name
)
if (pageType.value != DetailsPageType.Add) {
loadData()
}
}
Activity中访问:
override fun initOnCreate(savedInstanceState: Bundle?) {
viewModel.initOnCreated(intent)
when (viewModel.pageType.value) {
DetailsPageType.Add -> setBackArrow("新增")
DetailsPageType.Modify -> setBackArrow("编辑")
DetailsPageType.Details -> setBackArrow("详情")
}
}
XML中使用:
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
enable="@{viewModel.pageType != DetailsPageType.Details}"
required="@{viewModel.pageType != DetailsPageType.Details}"
title='@{"运单号:"}'
value='@={viewModel.bean.waybillNo}' />
注意: 在XML的DataBinding表达式中,直接使用viewModel.pageType即可,不需要.value。
错误排查流程
当遇到编译错误时,按以下顺序排查:
第1步:检查DataBinding错误
如果看到android.databinding.tool.util.LoggedErrorException:
-
检查import语句的包名
- ✅
com.lukouguoji.module_base.common.DetailsPageType - ❌
com.lukouguoji.module_base.constant.DetailsPageType
- ✅
-
检查枚举值是否正确
- ✅
DataLayoutType.INPUT、SPINNER、DATE - ❌
DataLayoutType.INTEGER(不存在) - ✅
DetailsPageType.Modify(编辑) - ❌
DetailsPageType.Edit(不存在)
- ✅
-
移除不支持的属性
- 移除RecyclerView的
items属性 - 改为在Activity中手动更新adapter
- 移除RecyclerView的
第2步:检查Kotlin编译错误
如果看到Unresolved reference:
-
检查import语句
IOnItemClickListener→com.lukouguoji.module_base.interfaces.IOnItemClickListenerobserve→com.lukouguoji.module_base.impl.observe
-
检查常量是否存在
- 确认
Constant.Key.PAGE_TYPE已定义 - 确认
Constant.Key.ID已定义
- 确认
-
检查字段名称
UploadBean使用newName,不是url
第3步:检查协程相关错误
如果看到suspend function相关错误:
-
添加必要的import
import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -
在协程中调用emit
viewModelScope.launch { FlowBus.with<String>(event).emit(data) }
第4步:清理并重新构建
如果上述都检查过,仍有问题:
# 清理项目
./gradlew clean
# 重新构建
./gradlew assembleDebug
最佳实践建议
1. 开发前检查清单
- 确认
DetailsPageType在common包 - 确认
IOnItemClickListener在interfaces包 - 确认
Constant.Key.PAGE_TYPE常量已定义 - 熟悉
DataLayoutType和DetailsPageType的枚举值
2. 代码编写规范
- ✅ 使用
MutableLiveData声明pageType,不用普通变量 - ✅ RecyclerView不使用
items属性,改用手动更新adapter - ✅ FlowBus的
emit()必须在viewModelScope.launch中调用 - ✅
observe扩展函数需要单独import - ✅ 图片上传使用
UploadBean.newName字段
3. 参考已有代码
遇到问题时,优先参考项目中已有的类似实现:
- 查看
AccidentVisaDetailsViewModel了解pageType的LiveData用法 - 查看
GncShouYunListActivity了解FlowBus的正确使用 - 查看现有的编辑页面了解图片上传的完整流程
快速修复命令
# 查找DetailsPageType的正确包名
grep -r "enum class DetailsPageType" module_base/src --include="*.kt"
# 查找IOnItemClickListener的正确包名
find module_base/src -name "IOnItemClickListener.kt"
# 查找DataLayoutType的枚举值
grep -A 5 "enum class DataLayoutType" module_base/src --include="*.kt"
# 查找UploadBean的字段定义
grep -A 10 "class UploadBean" module_base/src --include="*.kt"
通过遵循这些规范和检查清单,可以避免大部分常见的编译错误。