16 KiB
16 KiB
CLAUDE.md
项目开发指南 - 航空物流App
项目概述
AirLogistics - Android原生应用,航空物流全流程管理
- 包名: com.lukouguoji.aerologic
- 版本: 1.7.9 (API 24-30)
- 架构: MVVM + 组件化 + Kotlin + DataBinding
- 屏幕: 横屏 1152dp × 720dp
快速构建
./gradlew assembleDebug # 构建Debug版本
./gradlew clean # 清理构建
核心架构
MVVM层级
Activity → BaseBindingActivity → ViewModel → BaseViewModel/BasePageViewModel → API
关键基类
- BaseBindingActivity: DataBinding + ViewModel自动绑定
- BaseViewModel: Loading管理、协程支持
- BasePageViewModel: 分页列表(含PageModel)
- CommonAdapter + BaseViewHolder: 列表适配器
- PadSearchLayout: 搜索区域输入控件
- PadDataLayout: 数据展示/编辑控件
标准Activity模板
@Route(path = ARouterConstants.ACTIVITY_URL_XXX)
class XxxActivity : BaseBindingActivity<ActivityXxxBinding, XxxViewModel>() {
override fun layoutId() = R.layout.activity_xxx
override fun viewModelClass() = XxxViewModel::class.java
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("页面标题")
binding.viewModel = viewModel
// 初始化UI
}
}
标准ViewModel模板
列表页ViewModel:
class XxxListViewModel : BasePageViewModel() {
val searchText = MutableLiveData<String>()
val itemLayoutId = R.layout.item_xxx
val itemViewHolder = XxxViewHolder::class.java
override fun getData() {
val params = mapOf(
"page" to pageModel.page,
"limit" to pageModel.limit,
"searchText" to searchText.value
).toRequestBody()
launchLoadingCollect({ NetApply.api.getXxxList(params) }) {
onSuccess = { pageModel.handleListBean(it) }
}
}
override fun onItemClick(position: Int, type: Int) {
val bean = pageModel.rv!!.commonAdapter()!!.getItem(position) as XxxBean
// 跳转详情
}
}
详情页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() }
}
}
}
编辑页ViewModel:
class XxxAddViewModel : BaseViewModel() {
val pageType = MutableLiveData(DetailsPageType.Add) // 必须用LiveData
var id = ""
val dataBean = MutableLiveData(XxxBean())
fun initOnCreated(intent: Intent) {
pageType.value = DetailsPageType.valueOf(
intent.getStringExtra(Constant.Key.PAGE_TYPE) ?: DetailsPageType.Add.name
)
if (pageType.value != DetailsPageType.Add) {
id = intent.getStringExtra(Constant.Key.ID) ?: ""
loadData()
}
}
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("保存成功")
viewModelScope.launch {
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).emit("refresh")
}
getTopActivity().finish()
}
}
}
}
网络请求
请求方法
// 带Loading请求
launchLoadingCollect({ NetApply.api.saveXxx(params) }) {
onSuccess = { /* 成功处理 */ }
onFailed = { code, msg -> /* 失败处理 */ }
}
// 无Loading请求(后台刷新)
launchCollect({ NetApply.api.getXxx() }) {
onSuccess = { /* 成功处理 */ }
}
// 参数转换
val params = mapOf("key" to "value").toRequestBody(removeEmptyOrNull = true)
API接口定义
// 位置: module_base/.../http/net/Api.kt
@POST("api/xxx/list")
suspend fun getXxxList(@Body data: RequestBody): BaseListBean<XxxBean>
@POST("api/xxx/details")
suspend fun getXxxDetails(@Query("id") id: String): BaseResultBean<XxxBean>
@POST("api/xxx/save")
suspend fun saveXxx(@Body data: RequestBody): BaseResultBean<SimpleResultBean>
核心UI组件
PadSearchLayout - 搜索输入框
<!-- 文本输入+扫码 -->
<com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
type="@{SearchLayoutType.INPUT}"
value="@={viewModel.waybillNo}"
hint="@{`请输入运单号`}"
icon="@{@mipmap/scan_code}"
setOnIconClickListener="@{(v)-> viewModel.scanWaybill()}" />
<!-- 日期选择 -->
<com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
type="@{SearchLayoutType.DATE}"
value="@={viewModel.date}"
icon="@{@mipmap/calendar}" />
<!-- 下拉选择 -->
<com.lukouguoji.module_base.ui.weight.search.layout.PadSearchLayout
type="@{SearchLayoutType.SPINNER}"
list="@{viewModel.statusList}"
value="@={viewModel.status}" />
类型: INPUT / INTEGER / SPINNER / DATE
PadDataLayout - 数据展示/编辑
<!-- 文本输入 -->
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
type="@{DataLayoutType.INPUT}"
title='@{"运单号:"}'
titleLength="@{5}"
value='@={viewModel.bean.waybillNo}'
enable="@{viewModel.pageType != DetailsPageType.Details}"
required="@{true}"
maxLength="@{11}" />
<!-- 下拉选择 -->
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
type="@{DataLayoutType.SPINNER}"
title='@{"状态:"}'
list="@{viewModel.statusList}"
value='@={viewModel.bean.status}' />
<!-- 多行输入 -->
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayout
type="@{DataLayoutType.INPUT}"
inputHeight="@{100}"
value='@={viewModel.bean.remark}' />
类型: INPUT / SPINNER / DATE
开发检查清单
⚠️ 重要提醒
新建Activity后必须在AndroidManifest.xml中注册,否则会报ActivityNotFoundException错误!
列表页开发(8步)
- 创建Bean (
module_base/.../bean/XxxBean.kt) - 添加API接口 (
Api.kt→getXxxList()) - 创建ViewHolder (继承
BaseViewHolder) - 创建ViewModel (继承
BasePageViewModel) - 创建Activity (继承
BaseBindingActivity) - 创建Layout (
activity_xxx_list.xml+item_xxx.xml) - 注册路由 (
ARouterConstants) - ⚠️ 在AndroidManifest.xml中注册Activity (
app/src/main/AndroidManifest.xml)
AndroidManifest.xml注册示例:
<!-- 在app/src/main/AndroidManifest.xml的<application>标签内添加 -->
<activity
android:name="com.lukouguoji.gnc.page.xxx.XxxActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:screenOrientation="userLandscape" />
关键代码:
// Activity中绑定分页
viewModel.pageModel.bindSmartRefreshLayout(
binding.srl, binding.recyclerView, viewModel, this
)
binding.recyclerView.addOnItemClickListener(viewModel)
详情页开发(5步)
- 添加API接口 (
getXxxDetails()) - 创建ViewModel (继承
BaseViewModel) - 创建Activity (含
companion object静态start方法) - 创建Layout
- ⚠️ 在AndroidManifest.xml中注册Activity
静态启动方法:
companion object {
@JvmStatic
fun start(context: Context, id: String) {
val starter = Intent(context, XxxDetailsActivity::class.java)
.putExtra(Constant.Key.ID, id)
context.startActivity(starter)
}
}
编辑页开发(6步)
- 添加API接口 (
saveXxx()+getXxxDetails()) - 创建ViewModel (
pageType使用MutableLiveData) - 创建Activity (多个静态start方法:
startForAdd/Edit/Details) - 创建Layout (根据
pageType控制enable) - FlowBus发送刷新事件
- ⚠️ 在AndroidManifest.xml中注册Activity
Activity多入口:
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) {
/* ... DetailsPageType.Modify ... */
}
}
常见业务场景
扫码
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) {
waybillNo.value = data?.getStringExtra(Constant.Result.CODED_CONTENT)
search()
}
}
图片上传
val result = UploadUtil.upload(filePath)
if (result.verifySuccess()) {
val imageUrl = result.data?.newName ?: "" // 注意是newName不是url
}
列表刷新事件
// 发送事件(在ViewModel中)
viewModelScope.launch {
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).emit("refresh")
}
// 接收事件(在Activity中)
import com.lukouguoji.module_base.impl.observe // 必须导入
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).observe(this) {
viewModel.refresh()
}
常用扩展函数
// Toast
showToast("提示信息")
// 验证非空
if (text.verifyNullOrEmpty("请输入内容")) return
// 空处理
val text = nullableString.noNull("默认值")
// 日期格式化
val dateStr = Date().formatDate() // "2025-11-12"
// 权限申请
permission(Manifest.permission.CAMERA) { openCamera() }
常见编译错误
1. DetailsPageType包名错误
<!-- ❌ 错误 -->
<import type="com.lukouguoji.module_base.constant.DetailsPageType" />
<!-- ✅ 正确 -->
<import type="com.lukouguoji.module_base.common.DetailsPageType" />
2. DataLayoutType枚举值错误
<!-- ❌ 错误: INTEGER不存在 -->
type="@{DataLayoutType.INTEGER}"
<!-- ✅ 正确: 使用INPUT -->
type="@{DataLayoutType.INPUT}"
可用类型: INPUT / SPINNER / DATE
3. DetailsPageType枚举值错误
// ❌ 错误: Edit不存在
DetailsPageType.Edit
// ✅ 正确: 使用Modify
DetailsPageType.Modify
可用类型: Add / Modify / Details
4. IOnItemClickListener包名错误
// ❌ 错误
import com.lukouguoji.module_base.impl.IOnItemClickListener
// ✅ 正确
import com.lukouguoji.module_base.interfaces.IOnItemClickListener
5. FlowBus使用错误
// ❌ 错误: observe需要单独导入
import com.lukouguoji.module_base.impl.FlowBus
// ✅ 正确
import com.lukouguoji.module_base.impl.FlowBus
import com.lukouguoji.module_base.impl.observe
// ❌ 错误: emit必须在协程中
FlowBus.with<String>(event).emit("data")
// ✅ 正确
viewModelScope.launch {
FlowBus.with<String>(event).emit("data")
}
6. 图片上传字段错误
// ❌ 错误: UploadBean没有url字段
val imageUrl = result.data?.url
// ✅ 正确: 使用newName字段
val imageUrl = result.data?.newName
7. pageType必须用LiveData
// ❌ 错误: DataBinding无法绑定
var pageType: DetailsPageType = DetailsPageType.Add
// ✅ 正确: 使用LiveData
val pageType = MutableLiveData(DetailsPageType.Add)
8. RecyclerView不支持items属性
<!-- ❌ 错误: items属性会导致编译错误 -->
<RecyclerView
items="@{viewModel.list}" />
<!-- ✅ 正确: 在Activity中手动更新 -->
<RecyclerView android:id="@+id/recyclerView" />
// Activity中
viewModel.list.observe(this) { data ->
binding.recyclerView.commonAdapter()?.refresh(data)
}
9. Constant.Key.PAGE_TYPE未定义
在module_base/.../common/Constant.kt中添加:
object Key {
const val ID = "id"
const val PAGE_TYPE = "pageType" // 添加这个
}
10. 资源引用错误(最常见的编译失败原因)
<!-- ❌ 错误: 引用不存在的资源会导致资源合并失败 -->
<TextView
android:background="@drawable/bg_custom"
android:textColor="@color/custom_color"
android:text="@string/custom_text" />
问题原因:
- 在布局文件中引用了项目中不存在的
drawable、color、string等资源 - 导致构建时资源合并失败,无法生成R文件
- 报错信息:
Resource compilation failed或AAPT: error: resource ... not found
正确做法:
- 使用已存在的资源 - 先检查资源是否存在
# 查找drawable资源
find module_base/src/main/res/drawable -name "bg_custom*"
# 查找color定义
grep "custom_color" module_base/src/main/res/values/colors.xml
# 查找string定义
grep "custom_text" module_base/src/main/res/values/strings.xml
- 主动创建缺失的资源 - 如果不存在则创建
<!-- 创建 drawable: module_base/src/main/res/drawable/bg_custom.xml -->
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white"/>
<corners android:radius="4dp"/>
</shape>
<!-- 添加 color: module_base/src/main/res/values/colors.xml -->
<color name="custom_color">#333333</color>
<!-- 添加 string: module_base/src/main/res/values/strings.xml -->
<string name="custom_text">自定义文本</string>
- 使用项目现有资源 - 避免重复创建
常用资源列表:
- 背景:
bg_white_radius_8,bg_gray_radius_4,bg_primary_radius_4 - 颜色:
white,black,colorPrimary,text_normal,text_gray,text_red - 文字: 优先直接写中文字符串,少用 string 资源
检查清单:
- ✅ 创建/修改布局文件前,确保引用的资源都存在
- ✅ 新增drawable时,在正确的module下创建(通常是
module_base) - ✅ 新增color/string时,添加到对应的values文件中
- ✅ 使用IDE的自动补全和资源预览功能,避免拼写错误
- ✅ 构建失败时,优先检查资源引用问题
错误排查流程
- 资源引用错误 → 检查drawable/color/string是否存在,主动创建缺失资源
- DataBinding错误 → 检查import包名、枚举值
- Unresolved reference → 检查import语句、常量定义
- suspend function错误 → 在
viewModelScope.launch中调用 - 仍有问题 →
./gradlew clean后重新构建
快速修复命令
# 查找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"
开发原则
- ✅ 资源引用必须存在 - 创建/修改布局前,确保drawable/color/string资源真实存在或主动创建
- ✅ 优先使用项目现有基类和封装
- ✅ 充分利用PadDataLayout和PadSearchLayout组件
- ✅ 遵循统一命名规范
- ✅ pageType用LiveData不用普通变量
- ✅ FlowBus.emit()必须在协程中调用
- ✅ 图片上传使用newName字段
- ✅ RecyclerView手动更新adapter不用items属性
- ✅ 新建Activity后必须在AndroidManifest.xml中注册
技术栈
- Kotlin + 协程 1.6.0
- Retrofit 2.6.1 + OkHttp 3.12.12
- DataBinding + LiveData
- ARouter 1.5.2
- SmartRefreshLayout 2.0.3
- Glide 4.15.1
签名配置: key.jks / 密码: 123321 / 别名: key