Files
aerologic-app/CLAUDE.md
2025-11-26 22:46:21 +08:00

24 KiB
Raw Blame History

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>

DataBinding + LiveData + ViewModel 核心知识

🎯 最关键的设置(最常见错误)

必须在 Activity 中设置 lifecycleOwner否则 XML 中的 LiveData 不会自动更新 UI

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

    // ⚠️ 关键:必须设置,否则 LiveData 无法自动更新 UI
    binding.lifecycleOwner = this
}

BaseBindingActivity 已自动设置,但如果手动使用 DataBinding 时务必记住!

📖 ViewModel 中 LiveData 的定义规范

class XxxViewModel : BaseViewModel() {
    // ✅ 推荐:对外暴露不可变的 LiveData
    private val _dataBean = MutableLiveData<XxxBean>()
    val dataBean: LiveData<XxxBean> = _dataBean

    // ✅ 简化写法:直接使用 MutableLiveData项目常用
    val searchText = MutableLiveData<String>()
    val pageType = MutableLiveData(DetailsPageType.Add)

    fun loadData() {
        // 主线程更新
        _dataBean.value = XxxBean()

        // 子线程更新(协程中不需要,已在主线程)
        // _dataBean.postValue(XxxBean())
    }
}

📝 XML 中 LiveData 的绑定方式

1. 单向绑定 @{}只显示ViewModel → UI

<layout>
    <data>
        <variable
            name="viewModel"
            type="com.lukouguoji.xxx.XxxViewModel" />
    </data>

    <!-- LiveData 自动解包:直接访问 value -->
    <TextView
        android:text="@{viewModel.dataBean.name}" />

    <!-- 条件判断 -->
    <View
        android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}" />

    <!-- 空值处理 -->
    <TextView
        android:text="@{viewModel.dataBean.name ?? `默认值`}" />

    <!-- 字符串拼接(使用反引号) -->
    <TextView
        android:text="@{`姓名:` + viewModel.dataBean.name}" />
</layout>

2. 双向绑定 @={}可编辑UI ↔ ViewModel

<!-- EditText 双向绑定 -->
<EditText
    android:text="@={viewModel.searchText}" />

<!-- PadSearchLayout 双向绑定 -->
<PadSearchLayout
    type="@{SearchLayoutType.INPUT}"
    value="@={viewModel.waybillNo}" />

<!-- PadDataLayout 双向绑定 -->
<PadDataLayout
    type="@{DataLayoutType.INPUT}"
    value="@={viewModel.dataBean.name}" />

双向绑定要求

  • 字段必须是 MutableLiveData
  • 用户输入时自动更新 ViewModel 的值
  • ViewModel 更新值时自动更新 UI

3. 点击事件绑定

<!-- Lambda 表达式(推荐) -->
<Button
    android:onClick="@{() -> viewModel.submit()}" />

<!-- 带参数 -->
<Button
    android:onClick="@{(v) -> viewModel.onItemClick(v, 1)}" />

<!-- 自定义监听器 -->
<PadSearchLayout
    setOnIconClickListener="@{(v) -> viewModel.scanWaybill()}" />

⚠️ DataBinding 常见错误与解决方法

错误 1忘记设置 lifecycleOwner

// ❌ 错误LiveData 变化但 UI 不更新
override fun initOnCreate(savedInstanceState: Bundle?) {
    binding.viewModel = viewModel
    // 忘记设置 lifecycleOwner
}

// ✅ 正确:必须设置
override fun initOnCreate(savedInstanceState: Bundle?) {
    binding.viewModel = viewModel
    binding.lifecycleOwner = this  // 关键!
}

错误 2在 XML 中传递 LiveData 而非值

<!-- ❌ 错误:某些属性只接受值,不接受 LiveData -->
<View
    android:visibility="@{viewModel.isVisible}" />
    <!-- 如果 isVisible 是 MutableLiveData<Boolean>,可能报错 -->

<!-- ✅ 正确DataBinding 会自动解包 LiveData -->
<View
    android:visibility="@{viewModel.isVisible ? View.VISIBLE : View.GONE}" />
    <!-- 三元表达式会自动解包 -->

错误 3import 类型错误

<!-- ❌ 错误 -->
<import type="android.view.View.VISIBLE" />

<!-- ✅ 正确 -->
<import type="android.view.View" />

错误 4字符串未使用反引号

<!-- ❌ 错误:普通引号会被识别为 XML 属性 -->
<TextView
    android:text="@{"姓名:" + viewModel.name}" />

<!-- ✅ 正确:使用反引号 ` -->
<TextView
    android:text="@{`姓名:` + viewModel.name}" />

错误 5访问 LiveData 的 value 属性

<!-- ❌ 错误DataBinding 会自动解包,不需要 .value -->
<TextView
    android:text="@{viewModel.dataBean.value.name}" />

<!-- ✅ 正确:直接访问 -->
<TextView
    android:text="@{viewModel.dataBean.name}" />

错误 6修改对象属性后 UI 不更新

// ❌ 错误修改对象内部属性LiveData 不会触发更新
val bean = dataBean.value
bean?.name = "新名称"
// UI 不会更新,因为 LiveData 的引用没变

// ✅ 正确:重新赋值 LiveData
val bean = dataBean.value?.copy(name = "新名称")
dataBean.value = bean

// ✅ 或者:使用 MutableLiveData + ObservableField
// 但项目中更推荐上面的方式

错误 7在 XML 中调用 suspend 函数

<!-- ❌ 错误suspend 函数不能直接在 XML 中调用 -->
<Button
    android:onClick="@{() -> viewModel.loadDataSuspend()}" />

<!-- ✅ 正确:在 ViewModel 中包装 -->
// ViewModel 中
fun loadData() {  // 普通函数
    launchLoadingCollect({ NetApply.api.getXxx() }) {
        onSuccess = { dataBean.value = it.data }
    }
}

🔍 XML DataBinding 调试技巧

1. 检查 Binding 类是否生成

# 清理重新构建
./gradlew clean
./gradlew assembleDebug

2. 查看 DataBinding 错误

  • XML 中的错误可能不会立即显示
  • 需要 Build 项目才能看到详细错误信息
  • 错误信息通常在 Build Output 中

3. 常见错误提示

Cannot find the setter for attribute 'android:text' with parameter type...
→ 检查属性类型是否匹配

Unresolved reference: viewModel
→ 检查 <variable> 声明和 import

cannot generate view binders
→ 检查 XML 语法错误,特别是 @{} 表达式

📋 DataBinding 开发检查清单

  • Activity 中设置 binding.lifecycleOwner = this
  • ViewModel 中需要双向绑定的字段使用 MutableLiveData
  • XML 中字符串使用反引号 `
  • XML 中不访问 LiveData 的 .value 属性
  • 修改对象属性后重新赋值 LiveData触发更新
  • 点击事件使用 Lambda 表达式
  • 正确 import 枚举和常量类
  • XML 错误需要 Build 项目才能看到

核心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步)

  1. 创建Bean (module_base/.../bean/XxxBean.kt)
  2. 添加API接口 (Api.ktgetXxxList())
  3. 创建ViewHolder (继承BaseViewHolder)
  4. 创建ViewModel (继承BasePageViewModel)
  5. 创建Activity (继承BaseBindingActivity)
  6. 创建Layout (activity_xxx_list.xml + item_xxx.xml)
  7. 注册路由 (ARouterConstants)
  8. ⚠️ 在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步)

  1. 添加API接口 (getXxxDetails())
  2. 创建ViewModel (继承BaseViewModel)
  3. 创建Activity (含companion object静态start方法)
  4. 创建Layout
  5. ⚠️ 在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步)

  1. 添加API接口 (saveXxx() + getXxxDetails())
  2. 创建ViewModel (pageType使用MutableLiveData)
  3. 创建Activity (多个静态start方法: startForAdd/Edit/Details)
  4. 创建Layout (根据pageType控制enable)
  5. FlowBus发送刷新事件
  6. ⚠️ 在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" />

问题原因:

  • 在布局文件中引用了项目中不存在的 drawablecolorstring 等资源
  • 导致构建时资源合并失败无法生成R文件
  • 报错信息: Resource compilation failedAAPT: error: resource ... not found

正确做法:

  1. 使用已存在的资源 - 先检查资源是否存在
# 查找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
  1. 主动创建缺失的资源 - 如果不存在则创建
<!-- 创建 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>
  1. 使用项目现有资源 - 避免重复创建

常用资源列表:

  • 背景: 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的自动补全和资源预览功能避免拼写错误
  • 构建失败时,优先检查资源引用问题

错误排查流程

  1. 资源引用错误 → 检查drawable/color/string是否存在主动创建缺失资源
  2. DataBinding错误 → 检查import包名、枚举值
  3. Unresolved reference → 检查import语句、常量定义
  4. suspend function错误 → 在viewModelScope.launch中调用
  5. 仍有问题./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资源真实存在或主动创建
  • 必须设置 lifecycleOwner - Activity 中 binding.lifecycleOwner = thisBaseBindingActivity 已自动设置)
  • 优先使用项目现有基类和封装
  • 充分利用PadDataLayout和PadSearchLayout组件
  • 遵循统一命名规范
  • pageType用LiveData不用普通变量
  • XML中字符串拼接使用反引号不访问LiveData的.value属性
  • 修改对象属性后重新赋值LiveData才能触发UI更新
  • 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

  • 当使用PadDataLayoutNew 控件的时候titleLength 通常设置为5
  • 在每个页面布局时,我会给你截图,请务必尽可能还原图片上的页面设计,而不是推测、假想。如果有困难(例如图片看不清,不明白的地方)一律要询问我,禁止自己想象。
  • layout xml 最佳布局实践参考:
    • module_gjc/src/main/res/layout/activity_gjc_weighing_record_details.xml
    • module_gjc/src/main/res/layout/item_gjc_check_in_record.xml
    • module_gjc/src/main/res/layout/activity_gjc_box_weighing_details.xml
    • module_gjc/src/main/res/layout/activity_gjc_inspection.xml