112 KiB
安卓端调试成功全过程问题总结与修复指南
文档类型:经验沉淀文档(非简单周报)
维护人:黄镇堡
最后更新:2026-04-20
项目:智慧医养 mall(uni-app x)
分支:huangzhenbao-admin
AppID:__UNI__81482FF
目录
- 文档说明
- 时间范围与排查范围
- 总体结论
- 生成本地打包APP资源阶段——UTS 语法问题
- Run 到 Android 端时的报错与修复全过程
- JS / UTS / Kotlin 语义差异总结
- Android 适配与页面运行问题总结
- 额外发现的重要隐藏问题
- 面向后续开发的编码规范清单
- 面向后续排查的标准流程清单
- 关键文件与关键改动索引
- 结语
1. 文档说明
1.1 目的
本文档记录了将 uni-app x 商家端项目从"能在小程序/H5运行"推进到"能在 Android 真机正常调试运行"的完整过程。
内容覆盖三个层次:
- 编译层:HBuilderX 的 UTS → Kotlin 编译阶段报错
- 构建层:Android Studio
assembleDebug阶段 Kotlin 符号缺失报错 - 运行层:APK 安装后页面布局异常、逻辑错误、跳转失败
1.2 阅读建议
| 角色 | 建议阅读章节 |
|---|---|
| 新接手本项目的开发者 | 全部 |
| 遇到 UTS 编译报错 | §4 + §6 |
| 遇到 Android Studio 构建失败 | §5 |
| 遇到 Android 页面布局/显示问题 | §7 |
| 快速查规范 | §9 + §10 |
1.3 项目技术栈
| 层次 | 技术 |
|---|---|
| 前端框架 | uni-app x(HBuilderX 5.07) |
| 前端语言 | UTS(TypeScript 子集,编译为 Kotlin) |
| 后端 | Supabase(PostgreSQL + Edge Functions) |
| Android 构建 | Android Studio Ladybug + assembleDebug |
| Android 最低版本 | API 21(Android 5.0) |
2. 时间范围与排查范围
2.1 时间范围
- 开始:2026-04-13(开始尝试 Android 本地打包)
- 完成:2026-04-20(merchant 端能够在 Android 上正常运行)
2.2 关键提交节点
| 提交 Hash | 提交信息 | 意义 |
|---|---|---|
5ab4789d |
保存能够打包成功的版本 | 基线:首次编译通过,但 Android 运行仍有问题 |
8765e948 |
小程序条件编译 | 分离小程序/Android 条件编译 |
5473f0fc / 3c256812 |
修复页面展示bug | 布局适配修复 |
f99bd5a7 |
merchant能够在安卓上面进行运行 | 终点:Android 端完整可用 |
2.3 排查范围
- 页面:所有
pages/mall/merchant/**(共约 30 个页面)+pages/user/login - 公共组件:
components/merchant-tabbar/、utils/eventHelpers.uts(新增) - 配置文件:
pages.json、manifest.json、fix_kt_files.ps1 - Android 项目:
D:\Android\Project\mall\app\src\main\java\
2.4 主要错误文件索引
| 文件 | 内容 |
|---|---|
errors_round1_clean.txt |
A阶段第一轮 UTS 编译错误(清洗版) |
errors_round2.txt |
A阶段第二轮 UTS 编译错误 |
D:\Android\Project\mall\报错信息.txt |
B阶段 Android Studio Kotlin 符号缺失错误 |
kotlinc_errors_real.txt |
独立 kotlinc 编译尝试的过滤结果(已确认无参考价值) |
build_output_r1_android.txt |
Android Studio assembleDebug 完整输出 |
3. 总体结论
3.1 根本性结论
uni-app x 的 UTS 并不是 JavaScript,是一门独立的强类型语言,编译到 Kotlin 后遵循 Kotlin 的严格类型规则。
大量问题来源于以 JS 习惯写 UTS:
- JS 中合法的表达式(
if (str)、a === b、obj.field)在 UTS/Kotlin 中要么报编译错误,要么产生意料之外的运行时行为。 - CSS 在 Web 浏览器中自动降级处理,在 Android 原生渲染器中不行,无效属性直接被忽略或崩溃。
3.2 三阶段问题分布
┌────────────────────────────────────────────────────────────────────────┐
│ 阶段A:HBuilderX "生成本地打包APP资源" │
│ 工具:HBuilderX 5.07 UTS编译器 │
│ 产物:.kt 文件到 unpackage/dist/build/app-android/ │
│ 问题:UTS 语法错误(13类),编译直接失败 │
├────────────────────────────────────────────────────────────────────────┤
│ 阶段B:Android Studio assembleDebug │
│ 工具:Gradle + Kotlin 编译器 │
│ 产物:app-debug.apk │
│ 问题:Kotlin 符号 unresolved(uni SDK AAR缺失)、=== 运行时语义错误 │
├────────────────────────────────────────────────────────────────────────┤
│ 阶段C:APK 安装到真机/模拟器运行 │
│ 工具:Android 真机 │
│ 问题:布局截断、顶部遮挡、双层TabBar、跳转白屏、无法滚动 │
└────────────────────────────────────────────────────────────────────────┘
3.3 问题数量统计
| 阶段 | 问题分类数 | 已解决 | 备注 |
|---|---|---|---|
| A(UTS编译) | 13类 | 13类 | |
| B(Android构建) | 11类 | 9类 | AAR依赖需要补充;getPushClientId待重写 |
| C(运行适配) | 14类 | 14类 | 含flex-direction、scroll-into-view ID、safe-area双重兜底 |
| 隐藏问题 | 6类 | 6类 | 含AkReq链路模式、sessionUser访问模式 |
| 合计 | 44类 | 42类 |
4. 生成本地打包APP资源阶段——UTS 语法问题
触发场景:在 HBuilderX 中点击"发行 → 本地打包 → 生成本地打包App资源",UTS 编译器报错,
.kt文件无法生成。错误来源文件:
errors_round1_clean.txt、errors_round2.txt
4.1 非严格布尔条件(最常见)
错误信息
condition type mismatch: String but Boolean expected
condition type mismatch: Int? but Boolean expected
根因
JavaScript 有 truthy/falsy 机制(空字符串、0、null 均为 falsy),UTS/Kotlin 的 if 条件必须是 Boolean 类型,不接受其他类型隐式转换。
错误写法 → 修复写法
// ❌ JS习惯写法(UTS报错)
if (this.merchantId) { ... }
v-if="notice.tag"
if (item.url) { ... }
// ✅ 修复后
if (this.merchantId !== '') { ... }
v-if="notice.tag !== ''"
if (item.url.length > 0) { ... }
// ❌ 可空类型
if (result.data) { ... } // result.data: string | null
// ✅ 修复后
if (result.data != null && result.data !== '') { ... }
影响文件:profile.uvue、index.uvue、messages.uvue、finance.uvue 等大量文件。
4.2 onLoad 参数字段访问
错误信息
unresolved reference 'type'
unresolved reference 'orderId'
根因
onLoad(options: any) 中的 options 在 Android UTS 运行时是 UTSJSONObject,不能用点访问(.field)读取属性;as any 掩盖了类型问题,运行时返回 null。
// ❌ 错误写法
onLoad(options: any) {
const type = options.type // unresolved reference
const id = options?.orderId as string
}
// ✅ 修复写法
onLoad(options: UTSJSONObject | null) {
if (options != null) {
const type = options.getString('type') ?? ''
const id = options.getString('orderId') ?? ''
}
}
4.3 any[] 泛型不兼容
错误信息
type mismatch: inferred type is Array<Any?> but Array<UTSJSONObject> was expected
根因
UTS 不支持 any[],Android 端数组必须有明确的元素类型。
// ❌ 错误写法
const rawData = response.data as any[];
const item = rawData[0] as UTSJSONObject;
// ✅ 修复写法
const rawData = response.data as Array<UTSJSONObject>;
const item = rawData[0]; // 已知类型,不需要二次 as
4.4 UTSJSONObject 点访问
错误信息
unresolved reference 'name'
unresolved reference 'total_sales'
根因
UTSJSONObject 不是普通对象,字段不能用点访问,必须用方法访问。
// ❌ 错误写法
const name = shopInfo.name;
const count = shopInfo.total;
// ✅ 修复写法(按值类型选方法)
const name = shopInfo.getString("name") ?? "";
const count = shopInfo.getNumber("total") ?? 0;
const flag = shopInfo.getBoolean("is_active") ?? false;
const sub = shopInfo.getJSON("address"); // 嵌套对象
const list = shopInfo.getArray("items"); // 嵌套数组
4.5 request/uploadFile 回调类型不匹配
错误信息
argument type mismatch: AkRequestSuccessResult is not assignable to RequestSuccess<T>
return type mismatch: AkUploadFileSuccessResult vs UniUploadFileSuccess
根因
自定义请求封装 AkReq 的回调类型(AkRequestSuccessResult)与 uni-app x 的标准类型(RequestSuccess<T>)不兼容,UTS 强类型检查不允许隐式兼容。
修复:对所有使用 AkReq 回调的地方,显式声明回调参数类型为项目自定义类型,或用中间变量转换。
4.6 内联箭头函数事件处理器
错误信息
only expressions are allowed here
根因
UTS 模板中的 @click 不支持内联多行逻辑,只能调用命名方法。
<!-- ❌ 错误写法 -->
<view @click="() => { doA(); doB() }">
<!-- ✅ 修复写法 -->
<view @click="handleClick"></view
></view>
// script 中
handleClick() {
this.doA()
this.doB()
}
4.7 picker 事件类型错误
错误信息
UniPickerChangeEvent does not exist
argument type mismatch: UniInputEvent vs expected
根因
<picker> 的 @change 事件在 uni-app x Android 端使用 UniInputEvent(或通用事件接口),UniPickerChangeEvent 不存在。
修复:提取 utils/eventHelpers.uts 工具函数统一处理:
// utils/eventHelpers.uts
export function getPickerIdx(e: UniInputEvent): number {
return parseInt(e.detail.value as string) ?? 0;
}
export function getInputVal(e: UniInputEvent): string {
return (e.detail.value as string) ?? "";
}
模板使用:
<picker @change="(e) => { selectedIdx = getPickerIdx(e) }"></picker>
4.8 对象字面量未声明命名类型
错误信息
smart cast to 'String' is impossible
unresolved reference 'link'
根因
UTS 的对象字面量需要有已声明的 type 类型定义,否则编译器无法推断字段名,smart cast 失败。
// ❌ 错误写法
const item = { link: '/page', title: '标题' }
if (item.link) { ... } // unresolved reference 'link'
// ✅ 修复写法
type NavItem = { link: string; title: string }
const item: NavItem = { link: '/page', title: '标题' }
if (item.link !== '') { ... }
4.9 String? 赋值给 String
错误信息
assignment type mismatch: String? cannot be assigned to String
根因
UTSJSONObject.getString() 返回 String?(可空),不能直接赋给非空 String 变量。
// ❌ 错误写法
const name: string = obj.getString("name"); // String? → String 报错
// ✅ 修复写法
const name: string = obj.getString("name") ?? "";
4.10 Record<string, T> 类型使用
错误信息
unresolved reference 'Record'
type argument is not within its bounds
根因
UTS Android 端不支持 Record<string, T> 泛型,Kotlin 没有对应的直接映射。
// ❌ 错误写法
const map: Record<string, number> = {};
map["key"] = 1;
// ✅ 修复写法(用 if-else 替代或用 UTSJSONObject)
const map = {} as UTSJSONObject;
// 或者直接用具体 type 替代 Record
4.11 函数参数变量名不一致
错误信息
unresolved reference 'link'
unresolved reference 'item'
根因
事件处理函数的形参名与函数体内使用的变量名不一致(如声明时叫 item,调用时传的是 tip.link)。
修复:参见 §7 问题9——将事件处理器改为只接收基础类型参数,在模板层完成字段提取:
<!-- 模板:在调用处提取字段 -->
<view @click="viewDetail(tip.link)">
<!-- script:接收 string 类型 -->
viewDetail(link: string) { if (link.length > 0) { uni.navigateTo({ url: link
}) } }</view
>
4.12 可空接收者运算符调用
错误信息
operator call is prohibited on nullable receiver: String?
根因
String? 类型不能直接进行字符串运算,需要先用 ?? '' 确保非空。
// ❌ 错误写法
const total = price * count; // price: number | null
// ✅ 修复写法
const total = (price ?? 0) * count;
4.13 多级可选链类型推断失败
错误信息
Cannot infer type for this parameter. Please specify it explicitly.
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver
根因
UTS/Kotlin 不支持像 JS 那样无限级联的可选链(a?.b?.c?.d),多级嵌套时编译器无法推断中间类型。
// ❌ 错误写法
const val = response?.data?.items?.[0]?.name;
// ✅ 修复写法(分步判空)
if (response != null && response.data != null) {
const items = response.data as Array<UTSJSONObject>;
if (items.length > 0) {
const name = items[0].getString("name") ?? "";
}
}
4.14 UTS 编译阶段问题汇总表
| # | 错误类型 | 典型报错 | 核心修复 |
|---|---|---|---|
| 1 | 非布尔条件 | condition type mismatch: String |
!== '' / != null |
| 2 | onLoad参数点访问 | unresolved reference 'type' |
UTSJSONObject.getString() |
| 3 | any[] 泛型 |
Array<Any?> vs Array<UTSJSONObject> |
明确泛型类型 |
| 4 | UTSJSONObject点访问 | unresolved reference 'name' |
.getString() 等方法 |
| 5 | 回调类型不匹配 | AkRequestSuccessResult vs RequestSuccess |
显式类型声明 |
| 6 | 内联箭头函数 | only expressions are allowed |
抽取命名方法 |
| 7 | picker事件类型 | UniPickerChangeEvent does not exist |
eventHelpers.uts |
| 8 | 未声明类型对象 | smart cast impossible |
声明 type + as TypeName |
| 9 | String? → String |
assignment type mismatch |
?? '' 兜底 |
| 10 | Record<string,T> |
unresolved reference 'Record' |
改用具体类型或 UTSJSONObject |
| 11 | 参数名不一致 | unresolved reference 'link' |
模板层提取基础类型参数 |
| 12 | 可空运算符 | prohibited on nullable receiver |
?? 0 后运算 |
| 13 | 多级可选链 | Cannot infer type |
分步判空 |
5. Run 到 Android 端时的报错与修复全过程
触发场景:UTS 编译通过,生成
.kt文件后,复制到 Android Studio 项目执行assembleDebug,出现 Kotlin 编译错误;或 APK 安装后逻辑行为异常。错误来源文件:
D:\Android\Project\mall\报错信息.txt、build_output_r1_android.txt
5.1 问题链路概览
HBuilderX UTS编译 → .kt文件生成
↓
复制到 D:\Android\Project\mall\app\src\main\java\
↓
运行 fix_kt_files.ps1(必须!)
↓
Android Studio assembleDebug
↓
adb install / Run
5.2 问题1:main.uts / App.uvue 语法错误导致整个编译中止
现象:HBuilderX 在处理入口文件时报错,后续所有 .kt 文件无法生成。
根因:main.uts 中有 UTS 不支持的写法,或 App.uvue 的 <script setup> 函数签名有误。
修复:清理 main.uts 顶层副作用代码(如 import 语句外的立即执行逻辑),确保 App.uvue 生命周期函数签名简洁无歧义:
// App.uvue
onLaunch(opts: OnLaunchOptions | null) {
// 不要在此处写复杂初始化逻辑
}
5.3 问题2:@dcloudio/uni-components 找不到
现象
error: cannot find module '@dcloudio/uni-components'
根因:HBuilderX 的 uni_modules 依赖未正确安装,或版本不兼容。
修复:在 HBuilderX 中:菜单 → 工具 → 重新安装依赖(不是 npm install,是 HBuilderX 自身的依赖管理)。若仍失败,重启 HBuilderX 并重新导入项目。
5.4 问题3:=== / !== 在 Kotlin 中变成引用比较(最关键、影响最大)
现象:Android 端登录逻辑判断失效(loginType.value === 1 始终返回 false);角色判断失效(role === 'merchant' 永远不匹配)。
根因
HBuilderX 将 UTS 中的 === 直接输出为 Kotlin 的 ===,但 Kotlin 的 === 是引用相等(referenceEquals),而 JS 的 === 是值相等(对基础类型)。
| 语言 | === 语义 |
|---|---|
| JavaScript | 严格值相等(对基础类型与 == 等价) |
| Kotlin | 引用相等(referenceEquals),等价于 Java 的 == |
| Kotlin 值相等 | 使用 ==(调用 equals()) |
// 生成的 Kotlin 代码(错误)
if (loginType.value === 1) { ... } // Kotlin: 引用比较,Number(1) !== 1
// fix_kt_files.ps1 修复后
if (loginType.value == 1) { ... } // Kotlin: 值比较,正确
修复:fix_kt_files.ps1 全局替换:
$c = $c -replace ' === ', ' == '
$c = $c -replace ' !== ', ' != '
⚠️ 这是整个 Android 调试过程中最隐蔽的 bug。它不产生编译错误,只产生逻辑错误,且很难通过代码审查发现。每次 HBuilderX 重新生成
.kt文件后都必须重新运行此脚本。
5.5 问题4:登录后跳转到不存在的路由(白屏)
现象:商家账号登录成功,Android 页面停在白屏,不跳转。
根因:login.uvue 中 3 处跳转硬编码到 admin/homePage/index,该路由在精简后的 Android pages.json 中不存在:
// 错误写法(3处)
uni.reLaunch({ url: "/pages/mall/admin/homePage/index" });
修复:统一改为 Android 端实际存在的路由:
uni.reLaunch({ url: "/pages/mall/merchant/index" });
5.6 问题5:onLoad 参数 Android 端取不到值
现象:登录页收不到 URL 中的 redirect 参数,登录后无法跳回原页面。
根因:旧写法通过 getCurrentPages() 读取 options,在 Android 上 options 是 UTSJSONObject,点访问语法无效:
// 错误写法
const pages = getCurrentPages() as any[];
const currentPage = pages[pages.length - 1];
const opts = currentPage?.options as any;
const redirect = opts?.redirect as string | null; // Android: 始终 null
修复:改用 onLoad 钩子 + UTSJSONObject.getString():
import { onLoad } from "@dcloudio/uni-app";
const redirectPath = ref<string>("");
onLoad((opts) => {
if (opts != null) {
const optsObj = opts as UTSJSONObject;
redirectPath.value = optsObj.getString("redirect") ?? "";
}
});
5.7 问题6:Android Studio Kotlin 符号 unresolved(uni SDK AAR 缺失)
现象(来自 报错信息.txt):
error: unresolved reference: uni_showModal
error: unresolved reference: uni_showLoading
error: unresolved reference: ShowModalOptions
error: unresolved reference: UniShowModalResult
error: unresolved reference: uni_chooseImage
error: unresolved reference: ChooseImageOptions
error: unresolved reference: uni_setClipboardData
根因:D:\Android\Project\mall\app\libs\ 中缺少 uni-app SDK 的 AAR 文件,这些 AAR 包含 uni_showModal 等原生弹窗 API 的 Kotlin 绑定。
修复方向:从 HBuilderX 的本地打包资源包中提取标准 AAR 文件,添加到 app/libs/ 并在 build.gradle 中声明:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
}
⚠️ 注意:此问题目前尚未完全解决(临时绕过方案:在涉及这些 API 的页面上暂时注释掉相关调用)。这是当前 Android 调试的最后一个未闭合的问题。
5.8 问题7:BOM(Byte Order Mark)字符污染 Kotlin 文件
现象:某些 .kt 文件第一行出现 锘? 乱码前缀,Kotlin 编译器报错:
error: expecting a top level declaration
根因:Windows 环境下,某些编辑器或脚本保存文件时使用 UTF-8 with BOM,BOM 字节(\xEF\xBB\xBF)被直接写入文件头,导致 Kotlin 编译器无法识别文件开头。
修复:
在 fix_kt_files.ps1 或单独脚本中,用 [System.IO.File]::ReadAllText + [System.IO.File]::WriteAllText 重写文件(.NET 默认用无 BOM UTF-8)。或在文件保存时改用 UTF-8 无 BOM 编码。
5.9 问题8:fix_kt_files.ps1 必须在每次 HBuilderX 生成后运行
现象:修复过的 .kt 文件被重新生成,之前的修复失效,Android Studio 再次报错。
根因:每次在 HBuilderX 点"生成本地打包App资源",都会覆盖 unpackage/ 目录下的所有 .kt 文件,修复脚本的结果被清空。
当前流程(必须遵守):
1. HBuilderX → 发行 → 本地打包 → 生成本地打包App资源
2. 等待编译完成
3. 手动将 .kt 文件从 unpackage/ 复制到 Android Studio 项目
4. 运行:powershell -File D:\Android\Project\mall\fix_kt_files.ps1
5. Android Studio → Build → assembleDebug
规范建议:将步骤 3+4 整合为一个脚本自动执行。
5.10 fix_kt_files.ps1 完整修复内容
# 修复内容一览:
# 1. 全局替换 ' === ' → ' == ' (引用比较 → 值比较)
# 2. 全局替换 ' !== ' → ' != ' (引用不等 → 值不等)
# 3. 替换跳转路径 /pages/mall/admin/homePage/index → /pages/mall/merchant/index
# 4. 清除 MerchantTabBar import 和注册引用
5.11 问题9:login.uvue 深层业务逻辑重构(多问题叠加案例)
背景:login.uvue 是整个 merchant 流程的入口,其中的 parseRoleData 函数是 UTS 改造最复杂的单一案例——它在一个函数内叠加了 4 类 UTS 语法问题。
问题叠加清单
| # | JS 写法 | UTS 错误类型 | 修复 |
|---|---|---|---|
| 1 | 参数类型 any | null |
编译器无法推断数组操作 | 定义 ParseRoleInput 联合类型 |
| 2 | dataArray.length > 0 在 any 上 |
unresolved reference | 先 Array.isArray() 再 as Array<UTSJSONObject> |
| 3 | obj.getString('role') 结果直接参与 === 比较 |
String? 与字面量引用比较永远 false |
?? '' 兜底 + 改用 == |
| 4 | id != null 空字符串漏判 |
getString 返回 "" 而非 null |
改为 id !== '' |
修复前后对比
// ❌ 修复前(4类问题叠加)
const parseRoleData = (dataArray: any | null): UTSJSONObject | null => {
if (Array.isArray(dataArray) && dataArray.length > 0) {
const obj = dataArray[0] as UTSJSONObject;
const role = obj.getString("role"); // 返回 String?,未处理 null
const id = obj.getString("id"); // 返回 String?,未处理 null
if ((role === "merchant" || role === "admin") && id != null) {
// role === 'merchant':引用比较,永远 false
// id != null:空字符串 '' 也会通过
return { id, role } as UTSJSONObject;
}
}
return null;
};
// ✅ 修复后(每一步类型明确)
type ParseRoleInput = UTSJSONObject | Array<UTSJSONObject> | string | null;
const parseRoleData = (dataArray: ParseRoleInput): UTSJSONObject | null => {
if (Array.isArray(dataArray)) {
const arr = dataArray as Array<UTSJSONObject>; // 明确类型后才能调 .length
if (arr.length > 0) {
const obj = arr[0];
const role = obj.getString("role") ?? ""; // String? → String
const id = obj.getString("id") ?? ""; // String? → String
if ((role == "merchant" || role == "admin") && id !== "") {
// == 值比较;id !== '' 杜绝空字符串通过
return { id, role } as UTSJSONObject;
}
}
}
return null;
};
同一文件中的其它修复点(一并记录,避免遗漏)
// 1. sessionUser 可空链接:不能用 ?. 调用 UTSJSONObject 方法
// ❌
let sessionUid = sessionUser?.getString('id') ?? ''
// ✅
let sessionUid: string = sessionUser != null ? (sessionUser.getString('id') ?? '') : ''
// 2. accessData.getString 返回 String?,需要兜底
// ❌
const currRole = accessData.getString('role')
// ✅
const currRole = accessData.getString('role') ?? ''
// 3. loginType 比较
// ❌
if (loginType.value === 1) { ... }
// ✅(fix 脚本也会处理,但源码级改掉更好)
if (loginType.value == 1) { ... }
// 4. redirect 解码结果是 String?,需要兜底
// ❌
uni.redirectTo({ url: decodeURIComponent(redirect) })
// ✅
uni.redirectTo({ url: decodeURIComponent(redirect) ?? '' })
5.12 问题10:getPushClientId 回调因多重 UTS 限制被整段删除
现象:login.uvue 中注册推送 CID 的整段代码被删除,推送功能暂时失效。
被删除的原始代码:
// ❌ 以下代码在 UTS 中无法编译,已删除
uni.getPushClientId({
success: async (res: any) => {
const cid =
res && (res.clientid || res.cid)
? res.clientid || res.cid
: typeof res === "string"
? res
: null; // typeof 在 UTS/Kotlin 中不可用
if (cid != null && cid !== "") {
uni.setStorageSync("uni_push2_cid", cid);
}
},
});
无法编译的原因(4层):
| 问题 | 说明 |
|---|---|
res.clientid、res.cid |
res 为 UTSJSONObject,不支持点访问属性 |
typeof res === 'string' |
typeof 是 JS 运行时操作符,UTS/Kotlin 不支持 |
res && (...) |
&& 要求左侧是 Boolean,UTSJSONObject 不是 Boolean |
回调 async (res: any) |
UTS 对异步回调有严格签名要求,不允许 any 参数 |
后续正确实现方式(待开发):
// ✅ UTS 兼容写法(待补充到 login.uvue)
uni.getPushClientId({
success(res: GetPushClientIdResult) {
const cid = res.cid ?? "";
if (cid !== "") {
uni.setStorageSync("uni_push2_cid", cid);
}
},
fail(_err: UniError) {
/* 静默失败,不影响登录 */
},
});
⚠️ 当前状态:推送 CID 注册功能在 Android 端暂时缺失,登录流程其它功能不受影响。
6. JS / UTS / Kotlin 语义差异总结
此节总结三种语言在相同代码模式下的行为差异,是避免 bug 的核心参考材料。
6.1 比较运算符语义差异(最重要)
| 运算符 | JavaScript | UTS(编译到Kotlin) | Kotlin 实际执行 |
|---|---|---|---|
== |
宽松相等(类型转换) | 值相等 | 值相等(调用 equals())✅ |
=== |
严格值相等 | 输出为 Kotlin === |
引用相等(referenceEquals)❌ |
!= |
宽松不等 | 值不等 | 值不等 ✅ |
!== |
严格不等 | 输出为 Kotlin !== |
引用不等 ❌ |
结论:UTS 中禁止使用 === 和 !==,统一使用 == 和 !=。
6.2 条件判断 truthy/falsy 不支持
| 场景 | JavaScript | UTS/Kotlin |
|---|---|---|
if (str) |
非空字符串为 true | ❌ 类型错误 |
if (num) |
非零为 true | ❌ 类型错误 |
if (obj) |
非 null 为 true | ❌ 类型错误 |
if (arr.length) |
非零为 true | ❌ 类型错误 |
正确写法对照
| JS 写法 | UTS 正确写法 |
|---|---|
if (str) |
if (str !== '') |
if (num) |
if (num != 0) |
if (obj) |
if (obj != null) |
if (arr.length) |
if (arr.length > 0) |
if (str?.length) |
if (str != null && str.length > 0) |
6.3 空值兜底运算符
| 语言 | 空字符串兜底 | null 兜底 | 注意 |
|---|---|---|---|
| JavaScript | str || 'default' |
val ?? 'default' |
|| 对假值兜底 |
| UTS/Kotlin | str ?? 'default' |
val ?? 'default' |
|| 要求两侧都是 Boolean |
// ❌ UTS 中 || 不能用于字符串兜底
const name = obj.getString("name") || "匿名"; // 编译错误
// ✅ 使用 ??
const name = obj.getString("name") ?? "匿名";
6.4 短路运算符 && 的限制
// ❌ 左侧不是 Boolean,报错
const result = someString && doSomething();
// ✅ 方案1:显式判断
if (someString !== "") {
doSomething();
}
// ✅ 方案2:确保左侧是 Boolean
const result = someString !== "" && doSomething();
6.5 JSON / 对象数据访问模式
| 操作 | JavaScript | UTS |
|---|---|---|
| 读字符串字段 | obj.name |
obj.getString('name') ?? '' |
| 读数字字段 | obj.count |
obj.getNumber('count') ?? 0 |
| 读布尔字段 | obj.flag |
obj.getBoolean('flag') ?? false |
| 读嵌套对象 | obj.address |
obj.getJSON('address') |
| 读数组字段 | obj.items |
obj.getArray('items') |
| 动态键访问 | obj[key] |
obj.getString(key) ?? '' |
| 类型转换 | (arr as any[])[0] |
(arr as Array<UTSJSONObject>)[0] |
6.6 类型推导差异
// ❌ UTS lambda 必须标注参数类型
const process = (item) => item.name; // Cannot infer type
// ✅
const process = (item: UTSJSONObject) => item.getString("name") ?? "";
// ❌ 泛型推断
const arr: any[] = []; // UTS 不支持
// ✅
const arr: Array<UTSJSONObject> = [];
6.7 可选链差异
| 场景 | JavaScript | UTS |
|---|---|---|
| 单级 | obj?.field |
支持 ✅ |
| 多级 | a?.b?.c?.d |
❌ 编译器无法推断中间类型 |
| 带方法 | obj?.getString('x') |
不推荐,改用判空后调用 |
// ❌ 多级可选链
const val = response?.data?.items?.[0]?.name;
// ✅ 分步判空
if (response != null) {
const data = response.data;
if (data != null) {
const items = data as Array<UTSJSONObject>;
if (items.length > 0) {
const name = items[0].getString("name") ?? "";
}
}
}
6.8 跨平台行为差异矩阵
| 代码模式 | H5 | 微信小程序 | Android uni-app x |
|---|---|---|---|
if (str) |
✅ | ✅ | ❌ 编译错误 |
=== 值比较 |
✅ | ✅ | ❌ 变为引用比较 |
obj.field |
✅ | ✅ | ❌ unresolved reference |
any[] 类型 |
✅ | ✅ | ❌ 类型不匹配 |
box-shadow CSS |
✅ | 部分支持 | ❌ 不支持 |
min-height: 100% 撑高 |
✅ | 部分支持 | ❌ 子元素高度为0 |
#ifdef MP-WEIXIN 安全区 |
忽略 | ✅ | ❌ Android被排除 |
getCurrentPages().options.field |
✅ | ✅ | ❌ 始终返回null |
statusBarHeight 可能为null |
✅(有值) | ✅(有值) | ⚠️ 可能null |
6.9 综合速查表
| JS 写法(可能出错) | UTS/Android 正确写法 |
|---|---|
if (str) |
if (str !== '') |
if (num) |
if (num != 0) |
if (obj) |
if (obj != null) |
a === b(字符串/数字) |
a == b |
a !== b |
a != b |
val || default |
val ?? default |
obj.field |
obj.getString('field') ?? '' |
data as any[] |
data as Array<UTSJSONObject> |
arr[0] as UTSJSONObject |
arr[0](类型已知) |
a?.b?.c |
分步 if 判空 |
options.type in onLoad |
opts.getString('type') ?? '' |
const fn = (x) => |
const fn = (x: Type) => |
(e) => { a(); b() } 模板内联 |
抽取命名方法 |
sysInfo.statusBarHeight |
sysInfo.statusBarHeight ?? 20 |
Promise.all([...]) |
逐个 await(UTS不完全支持) |
6.10 typeof 操作符在 UTS 中不可用
背景:login.uvue 中因为使用了 typeof res === 'string' 导致整段推送 CID 代码无法编译(见 §5.12)。
| 平台 | 运行时类型判断 |
|---|---|
| JavaScript | typeof x === 'string'、x instanceof Array |
| UTS(编译到Kotlin) | x is String(Kotlin is 运算符)或通过类型系统静态保证 |
// ❌ UTS 不支持 typeof
if (typeof res === 'string') { ... }
// ✅ 替代写法1:已知类型范围时用 Kotlin is 运算符
if (res is String) { ... }
// ✅ 替代写法2:对 UTSJSONObject 字段,用访问方法的返回值是否为 null 来判断类型
if (obj.getString('key') != null) { ... } // 字段是字符串类型
if (obj.getJSON('key') != null) { ... } // 字段是嵌套对象
if (obj.getArray('key') != null) { ... } // 字段是数组
规范:UTS 代码中禁止使用 typeof;类型判断依赖编译期类型或 Kotlin is 运算符。
6.11 数值转换函数差异
| 操作 | JavaScript | UTS 可用性 |
|---|---|---|
parseInt('5') |
返回 number,失败返回 NaN |
✅ 返回 number,失败返回 null |
parseFloat('3.14') |
返回 number |
✅ 支持 |
Number(x) |
通用强制转换 | ⚠️ 支持有限,优先用 parseInt()/parseFloat() |
+str(一元加) |
隐式转数字 | ❌ 不支持 |
str as number |
—(JS无此语法) | ⚠️ 仅当类型系统能推断时有效 |
真实项目案例(来自 utils/eventHelpers.uts):
// picker change 事件:取选中索引
// ❌ 不推荐(Number() 支持有限)
const idx = Number(e.detail.value);
// ✅ 项目实际采用的写法
const idx: number = parseInt(e.detail.value as string) ?? 0;
// ↑ parseInt 返回 number | null,需要 ?? 兜底
规范:parseInt() / parseFloat() 的返回值必须用 ?? 默认值 处理,避免 null 传播。
7. Android 适配与页面运行问题总结
触发场景:APK 成功安装后,打开页面发现布局异常、内容截断、导航遮挡、跳转失败等视觉和交互问题。
主要涉及文件:
pages/mall/merchant/*.uvue
7.1 scroll-view 无法滚动 / 内容截断
页面:index.uvue、growth.uvue、messages.uvue、finance.uvue、orders.uvue
现象:列表只显示一小部分,无法向下滚动,底部内容不可达。
根因
/* 旧写法 */
.merchant-container {
min-height: 100%;
} /* ← 问题根源 */
.main-scroll {
height: 100%;
} /* ← 无有效父高度,=0 */
min-height: 100% 在 Web 中靠内容撑高,子元素 height: 100% 继承的是内容高度而非视口高度。在 Android 原生渲染中,布局计算一次性完成,min-height 不给子元素提供明确高度,scroll-view 拿到的可滚动高度为 0。
修复
/* 修复后 */
.merchant-container {
display: flex;
flex-direction: column;
height: 100%; /* 明确等于页面可用视口高度 */
}
.main-scroll {
flex: 1;
} /* 占满剩余空间 */
为什么有效:height: 100% 给容器明确的像素高度;flex: 1 让 scroll-view 在确定高度内伸展,原生引擎能计算真实 scrollable 区域。
规范:页面根容器必须使用 height: 100% + flex-column,scroll-view 使用 flex: 1,禁止用 min-height: 100%。
7.2 顶部内容被状态栏遮挡
页面:所有 29 个 navigationStyle: custom 的 merchant 页面
现象:顶部导航条贴着屏幕顶端,内容被系统状态栏(时间、电量区域)遮住约 20–40px。
根因:顶部安全区代码用条件编译包裹,Android 编译时被排除:
<!-- 旧写法:Android 编译时整个块消失 -->
<!-- #ifdef MP-WEIXIN -->
<view class="mp-tab-navbar">...</view>
<!-- #endif -->
修复:移除 <!-- #ifdef MP-WEIXIN --> 和 <!-- #endif --> 包裹,让顶部安全区对所有平台生效:
<!-- 修复后:所有平台都渲染 -->
<view class="mp-tab-navbar">...</view>
.mp-tab-navbar {
height: calc(88rpx + var(--status-bar-height));
padding-top: var(--status-bar-height); /* Android自动注入正确值 */
}
规范:--status-bar-height 对小程序和 Android 均有效,状态栏 padding 不能放在 #ifdef MP-WEIXIN 里。
7.3 双层 TabBar 叠加
页面:index.uvue、orders.uvue、messages.uvue、growth.uvue、profile.uvue
现象:底部出现两个导航栏叠加。
根因:pages.json 配置了系统 TabBar,同时页面内还保留了 <merchant-tab-bar> 自定义组件:
<!-- 页面内还有这个 ← 与系统TabBar并存 -->
<merchant-tab-bar :current="0"></merchant-tab-bar>
修复:从 5 个主 Tab 页删除自定义 TabBar 相关代码:
<!-- 删除 template 中 -->
<merchant-tab-bar :current="0"></merchant-tab-bar>
// 删除 script 中
import MerchantTabBar from '@/components/merchant-tabbar/MerchantTabBar.uvue'
components: { MerchantTabBar },
规范:自定义 TabBar 组件只用于小程序。Android 使用 pages.json 原生 TabBar。二者不能并存。
7.4 底部空白过大(safe-bottom 冗余)
页面:index.uvue、growth.uvue
现象:移除自定义 TabBar 后,底部仍有约 160rpx 空白。
根因:.safe-bottom { height: 160rpx } 是为自定义 TabBar 留空的,TabBar 移除后空间成为多余空白。
修复:
/* 旧 */
.safe-bottom {
height: 160rpx;
}
/* 新 */
.safe-bottom {
height: 20rpx;
}
并新增"到底提示"作为视觉终止符:
<view class="bottom-tip">
<text class="bottom-tip-text">—— 已经到底了 ——</text>
</view>
7.5 登录后跳转到不存在的路由(白屏)
详见 §5.5。
7.6 onLoad 参数取不到 query 字段
详见 §5.6。
7.7 大量 Web-only 页面被编译进 Android 包
现象:HBuilderX UTS 编译时报大量 unresolved reference,来自约 100+ 个 Web-only 页面(admin 后台、消费者端、分销等)。
根因:pages.json 是全平台统一的,包含约 150+ 个路由,Android 编译处理所有路由对应的文件。
修复:大幅精简 pages.json,只保留 Android 商家端所需的 ~30 个页面,移除:
pages/mall/admin/**(Web 管理后台,40+ 子包页面)pages/main/**(消费者端 5 个 Tab 页)doc_mall/子包(分销、直播、积分等)test/子包(API 调试工具)analytics/子包(数据分析报表)
规范:建议维护 pages.full.json(全功能)和一个 Android 精简版,通过脚本在打包前切换。
7.8 statusBarHeight 可能为 null
页面:chat.uvue
现象:头部导航高度偶尔计算为 0。
修复:
// 旧
const statusBarH = sysInfo.statusBarHeight; // 可能 null
// 新
const statusBarH = sysInfo.statusBarHeight ?? 20;
7.9 事件处理器接收联合类型导致 smart cast 失败
页面:growth.uvue
现象:点击列表项无响应。
根因:事件处理函数接收 TipType | GuideType | CourseType 联合类型,Android UTS 无法 smart cast,字段访问失败。
修复:模板层提取字段,函数接收基础类型:
<!-- 旧 -->
@click="viewDetail(tip)"
<!-- 新 -->
@click="viewDetail(tip.link)"
// 函数只接收 string
viewDetail(link: string) {
if (link.length > 0) { uni.navigateTo({ url: link }) }
}
7.10 Android 不支持的 CSS 属性
现象:卡片无阴影,渐变色显示异常。
根因:box-shadow 和多停止点 linear-gradient 在 Android 原生渲染层不支持。
修复:
/* 移除 box-shadow */
/* 渐变去掉百分比停止点 */
/* 旧 */
background: linear-gradient(135deg, #ff6b35 0%, #ff8c5a 100%);
/* 新 */
background: linear-gradient(135deg, #ff6b35, #ff8c5a);
规范:禁用 box-shadow;渐变只用两端无停止点写法。
7.11 字符串空值判断不兼容
页面:profile.uvue
现象:merchantId 未赋值时仍发起了数据请求。
// 旧(UTS报错或行为异常)
if (this.merchantId) { ... }
// 新
if (this.merchantId !== '') { ... }
7.12 flex-direction 默认值在 Android 与 Web 相反
现象:某些页面在 H5/小程序上是水平排列,在 Android 上变成垂直排列,或按钮堆叠成一列。
根因:
| 平台 | display: flex 的默认 flex-direction |
|---|---|
| Web 浏览器 | row(水平排列) |
| uni-app x(Android 渲染器) | column(垂直排列)— 与 Web 相反 |
如果 CSS 没有显式声明 flex-direction,Android 实际是 column,而 Web/小程序 是 row。
修复:需要水平排列的容器,必须显式声明 flex-direction: row:
/* ❌ 省略 flex-direction(在Android上默认 column,变成垂直堆叠) */
.nav-bar {
display: flex;
align-items: center;
}
/* ✅ 明确声明,行为确定 */
.nav-bar {
display: flex;
flex-direction: row;
align-items: center;
}
规范:所有 display: flex 容器必须显式声明 flex-direction: row 或 column,不能依赖平台默认值。
7.13 scroll-into-view 目标 ID 必须是合法标识符
页面:chat.uvue(消息列表自动滚动到最新消息)
现象:scroll-view 的 :scroll-into-view 绑定后,Android 上滚动不生效。
根因:scroll-into-view 要求目标元素的 id 只能包含字母、数字、下划线、连字符,且必须以字母开头。消息 ID 通常包含 : 或空格等非法字符(如 UUID 或时间戳字符串)。
修复:设置 ID 前对特殊字符做过滤:
// chat.uvue 中的实际处理
const getSafeId = (rawId: string): string => {
return "msg_" + rawId.replace(/[^a-zA-Z0-9]/g, "_");
};
// 模板中
// <view :id="getSafeId(msg.id)">
// :scroll-into-view="getSafeId(latestMsgId)"
规范:所有动态生成的元素 ID(用于 scroll-into-view 等),必须先做字符过滤,只保留字母、数字、下划线,并添加固定字母前缀。
7.14 chat 页面键盘弹出与底部安全区双重兜底
页面:chat.uvue
现象:在某些 Android 设备上,页面底部紧贴屏幕边缘,没有间距;在另一些设备上底部空白过多。
根因:Android 设备底部安全区高度不一——有实体 Home 键的设备为 0,全面屏设备为 20–40px。单纯使用 env(safe-area-inset-bottom) 时,无 Home 键模拟器会返回 0,导致紧贴底部。
实际处理方式(chat.uvue 中的做法):
<!-- 底部安全区:env() 优先,min-height 作为最低保障 -->
<view style="height: env(safe-area-inset-bottom); min-height: 40rpx;"></view>
env(safe-area-inset-bottom):在真实全面屏设备上提供正确的物理安全区高度min-height: 40rpx:在模拟器或有 Home 键的旧设备上(env 返回 0)保留基本间距
规范:底部安全区同时提供 env(safe-area-inset-bottom) 和 min-height 双重兜底,二者取较大值自动生效。
8. 额外发现的重要隐藏问题
这些问题在排查过程中发现,但不属于最初明确提出的类别,需要特别记录。
8.1 kotlinc_errors_current.txt(50MB)全是噪音,无参考价值
发现过程:尝试用独立 kotlinc 命令行编译 .kt 文件,产生了 34,261 行 "error" 日志,误以为找到了大量真实错误。
真相:第一条错误即 cannot access built-in declaration 'kotlin.String',说明 kotlinc 调用时未传入 SDK classpath(缺少 android.jar、uni-app SDK AAR 等),所有报错都是"缺少编译环境"的假错误,与真实代码 bug 无关。
教训:直接 kotlinc 编译 uni-app x 生成的 .kt 文件没有意义,必须通过 Android Studio/Gradle 的完整构建体系(带正确 classpath)才能得到真实错误。
8.2 push 订阅回调签名必须完整声明
发现:merchant/index.uvue 中推送订阅的回调省略了参数,UTS 要求回调签名必须与接口完全匹配:
// 旧(报 argument type mismatch)
.subscribe()
// 新(即使不用也要声明)
.subscribe((_status: string, _err: UniError | null) => {})
规范:UTS 回调参数即使不用也必须在签名中声明,用 _ 前缀标记为"有意忽略"。
8.3 BOM 字节导致首行乱码
发现:部分页面生成的 .kt 文件首行出现 锘? 前缀(BOM 字节 \xEF\xBB\xBF),Kotlin 编译器无法识别。
规范:保存文件统一使用 UTF-8 无 BOM 编码。用 .NET File.WriteAllText() 写文件默认无 BOM,是安全的。
8.4 any 类型掩盖了大量潜在的 Android 运行时错误
发现:项目中大量 as any、as any[] 在 HBuilderX 的宽松模式下通过编译,但在 Android 严格模式下全部需要改成具体类型。any 是 Android 适配的最大技术债。
规范:新代码禁止使用 any;旧代码中的 any 是 Android 适配的优先改造目标。
8.5 AkReq 数据处理链路:从 request 到 UTSJSONObject 的完整类型模式
背景:本项目使用 AkReq(封装自 uni_modules/ak-req/index.uts)发起请求,但无论用 AkReq 还是 uni.request,Android 端的响应数据处理链路必须遵循严格的类型模式,否则会在编译或运行时报类型错误。
完整数据流模式
// Step 1: 发起请求(AkReq 返回 AkRequestSuccessResult)
const result = await AkReq.post("/api/merchant/goods/list", { page: 1 });
// Step 2: 错误状态检查
if (result.error != null) {
console.error("请求失败", result.error);
return;
}
// Step 3: 断言响应体类型
// ❌ 错误写法:as any[] 在严格类型检查下不够安全
const rawList = result.data as any[];
// ✅ 正确写法:明确为 Array<UTSJSONObject>
const rawList = result.data as Array<UTSJSONObject>;
// Step 4: 遍历并用方法访问字段(不能点访问)
for (const item of rawList) {
const name = item.getString("name") ?? ""; // string
const price = item.getNumber("price") ?? 0; // number
const isOnSale = item.getBoolean("on_sale") ?? false; // boolean
const images = item.getArray("images"); // Array<any> | null
const detail = item.getJSON("detail"); // UTSJSONObject | null
}
常见错误模式速查
| 错误写法 | 报错信息 | 正确写法 |
|---|---|---|
result.data as any[] + arr[0].name |
unresolved reference 'name' |
as Array<UTSJSONObject> + arr[0].getString('name') |
const x = item.getString('x') 赋给 string |
String? cannot be assigned to String |
加 ?? '' |
result.raw?.getString('msg') |
UTS 中 ?. 在 any? 类型上失效 |
先 != null 判断,再调用方法 |
result.data.length(未断言类型) |
unresolved reference 'length' |
先 as Array<UTSJSONObject> |
为什么 H5 / 小程序 没有这个问题:JS 运行时允许在任何对象上点访问任意属性(返回 undefined 而非报错);Android UTS 编译到 Kotlin 后,类型系统要求每一步访问都有明确的编译期类型支撑。
8.6 sessionUser 可空对象的安全访问模式
发现:login.uvue 中 sessionUser 来自 uni.getStorageSync(),类型在运行时可能是 null;在 UTS 中对可空 UTSJSONObject 调用可选链 ?. + 方法调用会编译失败。
// ❌ 旧写法(两个问题:?. 用在 any 类型上 + getString 在可空链上)
let sessionUid = sessionUser?.getString("id") ?? "";
// ✅ 修复写法(先判 null,再调用)
let sessionUid: string =
sessionUser != null ? (sessionUser.getString("id") ?? "") : "";
模式推广:所有从 uni.getStorageSync() 读出的 UTSJSONObject 类型数据,必须先做 != null 判断,再用方法访问字段,不能用可选链 ?. 直接调用 UTSJSONObject 的访问方法。
// 通用模式
const stored = uni.getStorageSync("key") as UTSJSONObject | null;
const value: string = stored != null ? (stored.getString("field") ?? "") : "";
9. 面向后续开发的编码规范清单
9.1 类型安全规范
- 禁止使用
as any和any[];改用UTSJSONObject、Array<UTSJSONObject>或具体 type 定义 - 禁止
if (str)/if (num)/if (obj)非布尔条件;改用!== ''/!= 0/!= null - 禁止
===和!==;统一使用==和!= - 字符串兜底使用
??而非|| UTSJSONObject字段访问必须用.getString()/.getNumber()/.getBoolean()/.getJSON()/.getArray()等方法onLoad参数声明为UTSJSONObject | null,用.getString()取值- 所有来自
getSystemInfoSync()的数值属性(如statusBarHeight)必须加?? defaultValue - Lambda / 回调函数参数必须显式标注类型,不能依赖推断
- 禁止使用
typeof;类型判断依赖编译期类型或 Kotlinis运算符 - 数值转换统一使用
parseInt()/parseFloat(),结果必须加?? 默认值兜底 - 从
uni.getStorageSync()读出的 UTSJSONObject 必须先判!= null再调方法,不能用?.
9.2 布局规范(Android 端)
- 页面根容器使用
height: 100%+display: flex+flex-direction: column - 滚动容器使用
flex: 1,不使用height: 100%或min-height - 禁止使用
box-shadow;可用elevation或浅灰border替代 linear-gradient只使用两端颜色无停止点写法- 状态栏 padding 使用
var(--status-bar-height),不包裹在#ifdef MP-WEIXIN里 navigationStyle: custom的页面顶部必须有padding-top: var(--status-bar-height)- 系统 TabBar 和自定义 TabBar 组件不能并存,二选一
- 所有
display: flex容器必须显式声明flex-direction: row或column,不能依赖默认值(Android 默认column,Web 默认row) - 底部安全区同时写
height: env(safe-area-inset-bottom)和min-height兜底 scroll-into-view绑定的目标 ID 只能含字母、数字、下划线,且必须以字母开头
9.3 组件与事件规范
- 事件处理器函数接收基础类型(
string、number、boolean),不接收联合类型 - 复杂对象在模板层解构取字段值,传给事件函数
@click内联逻辑必须抽成命名方法,不允许内联箭头函数- picker/input 事件统一使用
utils/eventHelpers.uts中的工具函数 - 回调函数签名必须完整声明所有参数,不用的参数用
_前缀命名
9.4 多端兼容规范
- 新建页面时在文件头注释"目标平台"(Android / H5 / 小程序 / 全平台)
- Web-only 页面不纳入 Android 的
pages.json - 新增页面同时检查:①
pages.json路由 ② 页面内是否有平台特定代码 - 使用
#ifdef APP-ANDROID而非#ifdef MP-WEIXIN来隔离 Android 特定代码 - 登录跳转的目标路由必须在当前
pages.json中存在
9.5 构建流程规范
- 每次 HBuilderX"生成本地打包App资源"后,必须运行
fix_kt_files.ps1 - 不要用独立
kotlinc编译.kt文件排查错误,必须用 Android Studio Gradle - 保存文件统一使用 UTF-8 无 BOM 编码
10. 面向后续排查的标准流程清单
10.1 遇到 HBuilderX UTS 编译错误
1. 查看错误信息中的错误代码(UTS110xxxxx)
2. 对照 §4 的 13 类问题速查表定位问题类型
3. 重点检查:
- 条件表达式是否非 Boolean? → 加 !== '' / != null
- 是否使用了 obj.field? → 改 obj.getString('field')
- 是否使用了 any[] ? → 改 Array<UTSJSONObject>
- 是否有内联箭头函数? → 抽取命名方法
4. 修复后重新"生成本地打包App资源"
10.2 遇到 Android Studio Kotlin 编译错误
1. 检查是否运行了 fix_kt_files.ps1
2. 检查报错中的符号是否属于 uni-app SDK(uni_showModal 等)
→ 是:检查 app/libs/ 中的 AAR 文件
→ 否:检查 .kt 文件中是否有 BOM 字节(锘? 开头)
3. 检查是否有 === 出现(未被 fix 脚本替换)
4. 全量搜索错误的符号名,确认来自哪个 .uvue/.uts 源文件
5. 在源文件中修复,重新走完整流程
10.3 遇到 Android 运行时逻辑错误(看起来代码是对的但行为不对)
1. 优先怀疑 === / !== 比较(§5.4):
→ 在 fix_kt_files.ps1 运行结果中确认替换了多少处
2. 检查条件分支是否进入了错误分支
→ 打印关键值到 console.log 观察
3. 检查 UTSJSONObject 字段是否返回了 null
→ 改用 getString() 并加 ?? '' 兜底
4. 检查跳转路由是否存在于 pages.json
10.4 遇到 Android 页面布局问题
1. 内容截断/无法滚动:
→ 检查根容器是否使用了 min-height: 100%
→ 确认 scroll-view 的父元素有明确的 height
→ 改用 flex: 1 模型
2. 顶部内容被遮挡:
→ 检查页面是否 navigationStyle: custom
→ 检查顶部 padding 是否在 #ifdef MP-WEIXIN 里
→ 确认使用了 var(--status-bar-height)
3. 双层 TabBar:
→ 检查页面内是否还有 <merchant-tab-bar> 组件
→ 检查 pages.json 是否配置了 tabBar
4. CSS 样式异常:
→ 检查是否使用了 box-shadow
→ 检查 linear-gradient 是否有多个停止点
→ 参考 uni-app x 官方 CSS 支持列表
10.5 完整构建流程参考
[准备阶段]
1. 确认当前分支:git branch(应在 huangzhenbao-admin)
2. 确认 pages.json 是 Android 精简版
[HBuilderX 编译阶段]
3. HBuilderX → 发行 → 本地打包 → 生成本地打包App资源
4. 等待编译完成(底部显示"成功")
5. 检查是否有错误提示,参照 §4 和 §10.1 处理
[Android Studio 阶段]
6. 复制 .kt 文件到 D:\Android\Project\mall\app\src\main\java\
7. 运行:powershell -File D:\Android\Project\mall\fix_kt_files.ps1
8. Android Studio → Build → Clean Project
9. Android Studio → Build → assembleDebug
10. 检查 Build Output,参照 §5 和 §10.2 处理
[设备运行阶段]
11. adb install 或直接 Run
12. 按照 §10.3 和 §10.4 的清单检查运行时问题
11. 关键文件与关键改动索引
11.1 源代码关键改动(5ab4789d → f99bd5a7)
| 文件 | 主要改动 | 所属问题 |
|---|---|---|
pages/user/login.uvue |
3处跳转改为 merchant/index;onLoad参数改用 UTSJSONObject |
§5.5, §5.6 |
pages/mall/merchant/index.uvue |
删除 <merchant-tab-bar>;删除 #ifdef MP-WEIXIN 包裹;容器 min-height→flex:1 |
§7.1, §7.2, §7.3 |
pages/mall/merchant/growth.uvue |
同上布局修复;viewDetail 改接 string;safe-bottom 减小 |
§7.1, §7.9, §7.4 |
pages/mall/merchant/profile.uvue |
删 MerchantTabBar;merchantId 判空改为 !== '';any[]→Array<UTSJSONObject> |
§7.3, §7.11, §4.3 |
pages/mall/merchant/chat.uvue |
statusBarHeight ?? 20 |
§7.8 |
pages/mall/merchant/orders.uvue |
删 MerchantTabBar;布局 flex 化 | §7.1, §7.3 |
pages/mall/merchant/messages.uvue |
删 #ifdef MP-WEIXIN 包裹;删 MerchantTabBar |
§7.2, §7.3 |
pages/mall/merchant/finance.uvue |
布局 flex 化(.page { flex-column },.scroll { flex:1 }) |
§7.1 |
pages.json |
移除约 120 个 Web-only 页面路由 | §7.7 |
utils/eventHelpers.uts(新增) |
getPickerIdx()、getInputVal() 等工具函数 |
§4.7 |
11.2 Android 项目关键文件
| 文件 | 作用 |
|---|---|
D:\Android\Project\mall\fix_kt_files.ps1 |
每次必须运行:替换 ===→==、!==→!=、修复路径、清除 MerchantTabBar |
D:\Android\Project\mall\app\libs\ |
uni-app SDK AAR 文件(需补充) |
D:\Android\Project\mall\app\build.gradle |
fileTree(dir: 'libs', include: ['*.aar']) |
D:\Android\Project\mall\报错信息.txt |
B阶段 Kotlin 符号缺失的原始错误日志 |
11.3 参考文档
| 文件 | 内容 |
|---|---|
docs/Android打包自检清单.md |
打包前的逐项自查清单 |
docs/project_spec/UNI_APP_X_PAGE_FIX_GUIDE.md |
页面级修复指南 |
docs/fix_guide/android_debug_full_fix_guide.md |
本文档 |
11.4 关键提交 diff 查看命令
# 查看基线到最终的所有源码改动
git diff 5ab4789d f99bd5a7 -- "*.uvue" "*.uts" "*.json" "*.ps1"
# 查看某个文件的改动
git diff 5ab4789d f99bd5a7 -- pages/user/login.uvue
# 查看 pages.json 被移除的页面
git diff 5ab4789d f99bd5a7 -- pages.json | Select-String "^-" | Where-Object { $_ -match '"path"' }
12. 结语
12.1 核心教训
这次 Android 调试的问题几乎都源于一个根本误解:以为 UTS 是 JavaScript 的方言,实际上它是一门独立的强类型语言,编译到 Kotlin 后遵循完全不同的规则。
UTS 不是 JavaScript,不能用 JS 直觉写 UTS。
三个最重要的认知:
- 类型是真实的:
string不能当boolean用,null不会隐式转换,any是技术债 ===是陷阱:它不产生编译错误,但在 Android 上产生隐蔽的逻辑错误- 布局需要明确高度:Android 原生渲染不支持从内容反向推算父元素高度
12.2 最值得团队反复看的 10 条规则
===和!==在 Android 是引用比较,一律改用==和!=if条件只接受 Boolean,if (str)在 UTS 中是编译错误UTSJSONObject的字段必须用.getString()等方法访问,不能点访问any[]必须改为Array<UTSJSONObject>,as any是所有 Android 问题的根源- 页面根容器用
height: 100% + flex-column,scroll-view 用flex: 1,不用min-height - 状态栏 padding 用
var(--status-bar-height),不要包在#ifdef MP-WEIXIN里 - 系统 TabBar 和自定义 TabBar 不能并存,Android 端用系统 TabBar
- 每次 HBuilderX 生成
.kt文件后必须运行fix_kt_files.ps1 onLoad参数取字段用UTSJSONObject.getString(),不用currentPage.options.field- Web-only 页面必须从 Android 的
pages.json中移除,减少无效编译
文档完
最后修订:2026-04-20
下次更新触发条件:解决 AAR 依赖问题后(问题 §5.7)
第二轮安卓端调试修复记录(2026-04-21)
本轮时间:2026-04-21
调试阶段:APK 已能运行,本轮专注页面交互逻辑与视觉 Bug
背景:第一轮(§1-§12)解决了编译和基础运行问题;本轮基于已可运行的 APK,逐页排查交互逻辑、布局、路由、状态管理问题。
第二轮问题总览
| 序号 | 问题名称 | 页面/模块 | 问题类型 | 是否已解决 | 备注 |
|---|---|---|---|---|---|
| R2-01 | products.uvue 列表数据累加(3+3=6) | 商品管理 | 状态管理 | ✅ 已解决 | onShow 未重置分页 |
| R2-02 | 7 个页面 scroll-y 无效,无法滚动 | 商品/评价/会员/库存/促销/聊天/专属折扣 | 安卓布局 | ✅ 已解决 | scroll-y 属性不被识别 |
| R2-03 | live.uvue 底部白屏 | 直播 | 安卓布局 | ✅ 已解决 | min-height 导致高度计算错误 |
| R2-04 | product-edit.uvue 底部被遮挡 | 发布/编辑商品 | 安卓布局 | ✅ 已解决 | position:fixed 在非根视图中失效 |
| R2-05 | shop-edit.uvue 底部被遮挡 | 店铺设置 | 安卓布局 | ✅ 已解决 | 同上 |
| R2-06 | product-detail.uvue 底部被遮挡 | 商品详情 | 安卓布局 | ✅ 已解决 | 同上 |
| R2-07 | exclusive-discounts.uvue 无法滚动 | 专属折扣 | 安卓布局 | ✅ 已解决 | 根容器高度链断裂 |
| R2-08 | product-edit 标题显示"商品编辑" | 发布商品入口 | 页面配置 | ✅ 已解决 | 标题硬编码 |
| R2-09 | shop-edit 标题显示"店铺资料" | 店铺设置入口 | 页面配置 | ✅ 已解决 | 标题与入口按钮不一致 |
| R2-10 | members.uvue "添加等级"无效 | 会员管理 | 业务逻辑 | ✅ 已解决 | 按钮绑定了死变量 |
| R2-11 | members.uvue "删除等级"无效 | 会员管理 | 异步回调 | ✅ 已解决 | uni.showModal 回调在 Kotlin 中不可靠 |
| R2-12 | members.uvue 页面无法滚动 | 会员管理 | 安卓布局 | ✅ 已解决 | overflow:hidden 缺失 |
| R2-13 | settings.uvue 退出商家端无效 | 设置 | 路由+异步回调 | ✅ 已解决 | 同 R2-11,回调链不可靠 |
| R2-14 | App.uvue 退出后仍把用户拉回商家端 | 全局启动逻辑 | 路由守卫/状态恢复 | ✅ 已解决 | storage 未清干净 + store 内存态未重置 |
| R2-15 | members.uvue 编译失败(import 缺失) | 会员管理 | 编译问题 | ✅ 已解决 | 缺 import supa 和 type MemberLevel |
| R2-16 | members.uvue 模板结构损坏(</template> 缺失) |
会员管理 | 编译问题 | ✅ 已解决 | 上一轮修复时误注入 script 内容到 template 区 |
R2-01:商品管理列表数据累加("3+3=6" 问题)
现象
进入"商品管理"页,浏览后退出,再次进入页面,列表数据变成两倍(原来 3 条,变成 6 条);多次进出后持续累加。
出现位置
pages/mall/merchant/products.uvue,onShow 生命周期 + loadProducts() 方法
问题类型
状态管理 / 异步加载
根因分析
onShow 直接调用 loadProducts(),但没有先重置 page=1、products=[]、hasMore=true。
用户在页面内上拉加载了第2页后,page 已变为 2;退出再进来时,onShow 再次调 loadProducts(),此时 page=2,服务器返回第2页数据,追加到已有列表,视觉上看到 3+3=6。
为什么会这样
uni-app 的 onShow 每次页面显示都会触发(不只是第一次加载),而 onLoad 只触发一次。把列表数据请求放在 onShow 而没有重置分页状态,就会导致"每次回到页面都在原有列表上继续追加"。
修复方式
在 onShow 中调用 loadProducts() 之前先重置分页状态:
onShow() {
// 每次页面显示都要重置,防止"3+3=6"数据累加
this.page = 1
this.hasMore = true
this.products = []
this.loadProducts()
},
同步修复 _doDeleteProduct() 删除商品后的刷新逻辑(同样需要重置再刷):
async _doDeleteProduct(id: string) {
// ... 删除逻辑 ...
this.page = 1
this.hasMore = true
this.products = []
this.loadProducts()
}
涉及文件
d:\骅锋\mall\pages\mall\merchant\products.uvue
经验总结
规律:凡是有"上拉加载更多"功能的页面,在
onShow中刷新列表前必须先重置分页三件套:page=1、hasMore=true、list=[]。删除/更新后的刷新也要重置,而不是继续从当前页追加。
R2-02:多个页面 scroll-view 无法滚动(scroll-y 属性失效)
现象
安卓端页面列表区域无法下滑,内容被截断,只能看到屏幕内的部分。
出现位置
以下 7 个页面同时存在:
products.uvue(商品管理)reviews.uvue(评价管理)members.uvue(会员管理)inventory.uvue(库存管理)promotions.uvue(促销管理)chat.uvue(聊天)exclusive-discounts.uvue(专属折扣)
问题类型
安卓端布局 / 滚动容器
根因分析
模板中使用了 HTML/Web 风格的 scroll-y 布尔属性:
<!-- ❌ Web 写法,Android 端不识别 -->
<scroll-view scroll-y class="list-container"></scroll-view>
在 uni-app x 安卓端,scroll-view 的滚动方向必须通过 direction 属性指定,而不是 scroll-y / scroll-x 布尔属性。
为什么会这样
scroll-y 是 uni-app(老版本)和微信小程序的写法,继承自 Web 规范。uni-app x 的 Android 原生渲染器重新设计了滚动 API,改用 direction="vertical" / direction="horizontal",旧属性在安卓端被完全忽略(不报错,只是不生效)。
修复方式
批量将 scroll-y 替换为 direction="vertical":
<!-- ✅ Android 端正确写法 -->
<scroll-view direction="vertical" class="list-container"></scroll-view>
同时确保父容器有 overflow: hidden,scroll-view 本身有 flex: 1:
.list-container {
flex: 1;
overflow: hidden; /* 必须,否则高度无法约束 */
}
涉及文件
上述 7 个页面均已修改。
经验总结
规律:uni-app x Android 端,
scroll-view必须用direction="vertical"指定滚动方向,禁止使用scroll-y属性。同时父容器必须有overflow: hidden,否则 scroll-view 会展开为内容全高,表现为"可以看到全部内容但没有滚动效果"。
R2-03/R2-04/R2-05/R2-06/R2-07:底部白屏 / 内容被遮挡(position:fixed 失效 + 高度链断裂)
现象
live.uvue:页面底部有大块白屏product-edit.uvue:底部提交栏遮挡内容,或底部留有大段空白shop-edit.uvue:同上product-detail.uvue:底部操作按钮区遮挡商品信息exclusive-discounts.uvue:列表无法滚动,底部截断
出现位置
见各页面文件。
问题类型
安卓端布局 / 滚动容器 / position:fixed
根因分析
问题一:position: fixed 在安卓端非根视图中不可靠
/* ❌ 旧写法:submit-bar 用 fixed 定位 */
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
在 Web 浏览器中,position: fixed 相对于视口固定。在 Android uni-app x 中,如果页面根视图不是全屏(有 scroll-view 父容器等),fixed 定位的参照系会计算错误,导致按钮出现在错误位置或遮挡内容。
问题二:根容器使用 min-height: 100% 导致高度链断裂
/* ❌ 旧写法 */
.page {
min-height: 100%;
}
min-height: 100% 给元素设置了"至少等于视口高度"的约束,但没有给子元素提供可继承的明确高度。Android 原生布局计算时,子 scroll-view 的 flex: 1 无法找到参照高度,实际高度为 0,内容溢出到视口之外造成白屏。
修复方式
统一修复模式(所有含提交栏 / 操作栏的页面):
/* ✅ 正确的 flex 列布局 */
.page {
height: 100%; /* 明确高度,不用 min-height */
display: flex;
flex-direction: column;
overflow: hidden;
}
.form-scroll {
flex: 1; /* 占满剩余空间,可滚动 */
overflow: hidden;
}
.submit-bar {
flex-shrink: 0; /* 不缩小,始终在底部 */
/* 不再使用 position: fixed */
padding-bottom: env(safe-area-inset-bottom);
}
live.uvue 的修复(无提交栏,只有高度链问题):
.page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll {
flex: 1; /* 不再用 min-height */
}
涉及文件
d:\骅锋\mall\pages\mall\merchant\live.uvued:\骅锋\mall\pages\mall\merchant\product-edit.uvued:\骅锋\mall\pages\mall\merchant\shop-edit.uvued:\骅锋\mall\pages\mall\merchant\product-detail.uvued:\骅锋\mall\pages\mall\merchant\exclusive-discounts.uvue
经验总结
规律:含底部固定栏(提交按钮/操作按钮)的页面,统一用 flex 列布局:根容器
height:100% + flex-column + overflow:hidden→ 内容区flex:1 + overflow:hidden→ 底部栏flex-shrink:0。彻底放弃position:fixed,彻底放弃min-height:100%。
R2-08/R2-09:页面入口标题与页面内标题不一致
现象
- 点击"发布商品"入口进入后,页面标题显示"商品编辑"(而非"发布商品")
- 点击"店铺设置"入口进入后,页面标题显示"店铺资料"(而非"店铺设置")
出现位置
pages/mall/merchant/product-edit.uvuepages/mall/merchant/shop-edit.uvue
问题类型
页面配置 / 标题硬编码
根因分析
product-edit.uvue:<MerchantNavBar title="商品编辑" /> 硬编码,无论是"发布新商品"还是"编辑已有商品",标题始终是"商品编辑"。页面实际有 isEdit 状态字段区分两种场景,但未绑定到标题。
shop-edit.uvue:<MerchantNavBar title="店铺资料" />,而首页入口按钮文字是"店铺设置",两者不一致,让用户感觉进错了页面。
修复方式
product-edit.uvue:改为动态标题:
<MerchantNavBar :title="isEdit ? '商品编辑' : '发布商品'" />
shop-edit.uvue:统一改为"店铺设置":
<MerchantNavBar title="店铺设置" />
涉及文件
d:\骅锋\mall\pages\mall\merchant\product-edit.uvued:\骅锋\mall\pages\mall\merchant\shop-edit.uvue
经验总结
规律:页面标题不能在开发期随意硬编码,必须与调用方(首页/列表页的入口按钮文字、路由注释)保持一致。有多种进入场景(新建/编辑)的页面,必须用响应式绑定
:title="..."而不是静态字符串。
R2-10/R2-11/R2-12:会员管理页三连 Bug
问题背景
members.uvue 同时存在三个独立 Bug,用户体验表现为:无法添加等级、删除等级无效、页面无法滑动。
R2-10:添加等级按钮无效(死变量)
现象:点击"+ 添加等级",弹窗不出现,没有任何响应。
根因:
按钮绑定了 showAddLevel = true,但弹窗用的是 v-if="showEditModal"——两个完全不同的变量。showAddLevel 是死变量,没有任何地方读它。
<!-- ❌ 旧写法:设置了死变量 -->
<text @click="showAddLevel = true">+ 添加等级</text>
<!-- ✅ 修复后:调用方法 -->
<text @click="openAddLevel">+ 添加等级</text>
修复:新增 openAddLevel() 方法,重置 currentLevel 为空状态后设置 showEditModal = true:
openAddLevel() {
this.currentLevel = {
id: '',
name: '',
discount_rate: 0.85,
level_rank: this.levels.length + 1
} as MemberLevel
this.showEditModal = true
},
同时删除 data() 中的死字段 showAddLevel: false,替换为 showDeleteModal: false 和 pendingDeleteId: ''。
R2-11:删除等级无效(uni.showModal 回调在 Kotlin 中不执行)
现象:点击"删除",确认弹窗出现(uni.showModal),点击"确认",什么都没发生,等级没有被删除。
根因:
// ❌ 旧写法
deleteLevel(id: string) {
uni.showModal({
success: (res: UniShowModalResult): void => {
if (res.confirm === true) {
void this._doDeleteLevel(id) // ← 这里有两个问题
}
}
})
}
两个叠加问题:
-
this捕获失败:uni.showModal的success回调是一个void lambda,UTS 编译为 Kotlin 时会变成跨线程的异步回调(Kotlin 协程中的suspend上下文切换),this引用在回调执行时可能已失效。 -
void this._doDeleteLevel(id)不可靠:在非suspend的void lambda中调用async方法(编译后是 Kotlin suspend fun),UTS 不保证.then()/void的执行。实际表现是:回调被丢弃,_doDeleteLevel从未执行。
修复:用响应式状态弹窗完全替代 uni.showModal 回调模式:
// ✅ 修复后:响应式模态框
deleteLevel(id: string) {
this.pendingDeleteId = id
this.showDeleteModal = true
},
async confirmDelete() {
this.showDeleteModal = false
await this._doDeleteLevel(this.pendingDeleteId)
this.pendingDeleteId = ''
},
模板中新增删除确认弹窗:
<view class="modal" v-if="showDeleteModal">
<view class="modal-content">
<view class="modal-title">确认删除</view>
<text class="modal-desc"
>删除后该等级下所有用户等级将同步清空,是否继续?</text
>
<view class="modal-btns">
<text class="btn cancel" @click="showDeleteModal = false">取消</text>
<text class="btn danger" @click="confirmDelete">删除</text>
</view>
</view>
</view>
R2-12:会员管理页面无法滑动
现象:等级列表和客户列表不能下滑查看。
根因:
/* ❌ 旧写法:缺少 overflow: hidden */
.list-container {
padding: 20rpx;
flex: 1;
}
flex: 1 让容器占据剩余高度,但没有 overflow: hidden,Android 原生渲染器不约束 scroll-view 的高度,scroll-view 展开为内容全高,所有内容都"可见"了,但也就不需要滚动,表现为无法滑动。
修复:
.list-container {
padding: 20rpx;
flex: 1;
overflow: hidden;
}
涉及文件
d:\骅锋\mall\pages\mall\merchant\members.uvue
R2-13/R2-14:退出商家端无效(settings.uvue + App.uvue 双重问题)
现象
点击"设置 → 退出商家端",确认后:
- 有时什么都没发生
- 有时跳到登录页,但重新打开 App 又直接进入商家端
- 始终无法彻底退出
出现位置
pages/mall/merchant/settings.uvue(退出入口)App.uvue(启动恢复逻辑)
问题类型
路由问题 + 异步回调 + 状态管理
R2-13:settings.uvue 退出逻辑使用 Promise.then 回调链,在 Kotlin void lambda 中不执行
根因(与 R2-11 完全相同的根本原因):
// ❌ 旧写法
confirmLogout() {
uni.showModal({
success: (res: UniShowModalResult): void => {
if (res.confirm === true) {
supa.signOut().then(() => { // ← 问题核心
uni.removeStorageSync('user_id')
// ... 更多清理 ...
uni.reLaunch({ url: '/pages/user/login' })
}).catch(() => {
// ...
})
}
}
})
}
supa.signOut() 返回 Promise,在 uni.showModal 的 void lambda 中调用 .then()/.catch() 链,UTS 编译到 Kotlin 后,这个 Promise 链在非 suspend 上下文中不保证执行。实际结果是:.then() 里的所有清理代码一行都不执行。
修复:完全弃用 uni.showModal 回调,改为响应式模态框 + async doLogout() 方法:
data() {
return { showLogoutModal: false }
},
methods: {
confirmLogout() {
this.showLogoutModal = true
},
async doLogout() {
this.showLogoutModal = false
uni.showLoading({ title: '退出中...' })
try {
await supa.signOut() // await 保证执行顺序
} catch (_) {}
// 清理所有登录态 / 商家态 storage key
uni.removeStorageSync('akreq_access_token')
uni.removeStorageSync('akreq_refresh_token')
uni.removeStorageSync('akreq_expires_at')
uni.removeStorageSync('user_id')
uni.removeStorageSync('userInfo')
uni.removeStorageSync('merchant_id')
uni.removeStorageSync('merchant_idx_cache')
uni.removeStorageSync('merchant_orders_pending_tab')
uni.removeStorageSync('uni_push2_cid')
// 清除 store 内存态,防止 App.uvue 恢复逻辑误判
setIsLoggedIn(false)
setUserProfile({ username: '', email: '' })
uni.hideLoading()
uni.reLaunch({ url: '/pages/user/login' })
}
}
R2-14:App.uvue checkExistingSession 会把用户重新拉回商家端
根因:即使 settings.uvue 的退出成功跳到了登录页,下次 App 冷启动时,App.uvue 的 checkExistingSession() 还是会把用户送回商家端。
checkExistingSession() 的逻辑(简化):
checkExistingSession() {
// 路径一:内存 session 存在
const session = supa.getSession()
if (session.user != null) {
uni.reLaunch({ url: '/pages/mall/merchant/index' }) // ← 拉回
return
}
// 路径二:storage 中有 user_id
const savedUserId = uni.getStorageSync('user_id')
if (savedUserId != null && savedUserId != '') {
getCurrentUser().then((profile) => {
if (profile != null) {
uni.reLaunch({ url: '/pages/mall/merchant/index' }) // ← 拉回
}
})
}
}
为什么旧的退出后仍被拉回:
| 检查项 | 旧退出逻辑的问题 | 修复后的状态 |
|---|---|---|
supa.session 内存对象 |
supa.signOut() 未执行(.then 不执行),session 仍存在 |
await supa.signOut() 确保 session=null |
akreq_access_token storage |
.then() 未执行,未清除 |
removeStorageSync 在 async 方法中执行 |
user_id storage |
同上 | 同上清除 |
store.uts isLoggedIn 内存态 |
从未清除 | 新增 setIsLoggedIn(false) |
修复后,checkExistingSession 的两条路径均失效:
supa.getSession().user= null(session 已清除)uni.getStorageSync('user_id')= null(storage 已清除)- 用户停留在登录页,不再被拉回
涉及文件
d:\骅锋\mall\pages\mall\merchant\settings.uvue(主要修改)d:\骅锋\mall\App.uvue(无需改动,确认其逻辑本身正确,只要 storage 清干净就不会触发)
退出后清理的完整 storage key 清单
| Key | 说明 |
|---|---|
akreq_access_token |
JWT 访问令牌(AkReq 内存+storage 双写) |
akreq_refresh_token |
刷新令牌 |
akreq_expires_at |
令牌过期时间戳 |
user_id |
用户业务 ID(App.uvue 恢复的判断依据) |
userInfo |
用户信息缓存 |
merchant_id |
商家 ID |
merchant_idx_cache |
商家首页骨架屏缓存 |
merchant_orders_pending_tab |
订单页 tab 状态 |
uni_push2_cid |
推送客户端 ID |
经验总结
规律:在 UTS 中,凡是需要在用户交互后执行异步操作(清缓存、signOut、网络请求)的场景,必须使用
async method+await,不能依赖uni.showModal/uni.showActionSheet等系统弹窗的success回调中的 Promise 链。系统弹窗回调是void lambda,在 Kotlin 非 suspend 上下文中,Promise/.then/.catch 的执行不被保证。退出登录的完整清理清单:token(access+refresh+expires)+ 用户信息 + 商家 ID + 首页缓存 + 角色缓存 + store 内存态,缺一不可。
R2-15:members.uvue 编译失败(缺少 import supa 和 type MemberLevel)
现象
HBuilderX 报编译错误,Android Studio Kotlin 编译报大量 Unresolved reference: supa、Unresolved reference: MemberLevel、Unresolved reference: id/name/discount_rate 等。
根因
在一次多文件修复操作中,向 members.uvue 的 <script> 区域注入了新代码,但同时不小心把原有的 import supa 和 type MemberLevel 定义移除了(或从未加入)。
Kotlin 编译器对每一个符号引用都要求有对应的定义,supa 是全局数据库实例(80+ 处引用),MemberLevel 是数据类型(70+ 处引用),全部 unresolved。
修复方式
在 <script lang="uts"> 开头补回:
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type MemberLevel = {
id: string
name: string
discount_rate: number
level_rank: number
}
type UserInfo = {
// ...
}
涉及文件
d:\骅锋\mall\pages\mall\merchant\members.uvue
经验总结
规律:每次对
.uvue文件进行多处批量替换后,必须检查:①import语句是否完整 ② 自定义type定义是否存在 ③<template>/<script>/<style>三段式结构是否完整(可用Select-String快速验证)。
R2-16:members.uvue 模板结构损坏(</template> 缺失)
现象
HBuilderX 编译报错:
[uni:app-uvue] Invalid end tag.
file: members.uvue:492:1
第 492 行是 </script>,错误表明编译器在读到 </script> 时还没有找到对应的 </template> 关闭标签。
根因
在添加"删除等级确认弹窗"时,多文件替换操作的 oldString 匹配了包含 </view>\n\t</view>\n</template> 的区块,但生成的 newString 重新排布后,模板的 </view></template> 结构被混入了 <script> 的 type UserInfo = { 定义,导致 <template> 提前在错误位置"关闭",而真正的 </template> 标签丢失。
实际损坏状态:
<!-- 模板区域内容 -->
</view>
} ← 这里本应是 </view></template>,变成了 script 内容
type UserInfo = { ← script 内容出现在 template 区域
修复方式
手工在模板末尾(</view></view> 和 <script> 之间)补回缺失的 </view>\n</template> 结构,并清除误混入的 } 字符,使 <template> 正常闭合。
涉及文件
d:\骅锋\mall\pages\mall\merchant\members.uvue
经验总结
规律:对包含
</template>、</script>、</style>这类文档级边界标签的替换操作,oldString 不应包含这些边界标签;应该只替换边界内部的内容,让边界标签保持原位不动。批量替换后,必须用Select-String -Pattern "template|/template|script|/script"验证三段式结构完整性。
第二轮通用修复规律总结
规律一:uni.showModal / uni.showActionSheet 回调中不能用 Promise 链
| ❌ 旧模式 | ✅ 新模式 |
|---|---|
success: (res) => { asyncFn().then(...) } |
响应式状态弹窗 + async method + await |
void this._doSomething() 在 void lambda 中 |
在组件的 async method 中直接 await |
影响页面:settings.uvue(退出)、members.uvue(删除等级)、任何需要在系统弹窗确认后执行异步操作的页面。
规律二:安卓端三大滚动/布局模式
| 场景 | 正确模式 |
|---|---|
| 纯列表页 | 根 height:100% flex-column overflow:hidden + scroll-view flex:1 overflow:hidden |
| 含底部操作栏 | 同上 + 操作栏 flex-shrink:0(不用 position:fixed) |
| scroll-view 方向 | direction="vertical" 不是 scroll-y |
规律三:退出登录必须三清
- 清 Supabase 内存 session:
await supa.signOut() - 清所有 storage key:token × 3 + user_id + 商家相关 key × 4
- 清 store 内存态:
setIsLoggedIn(false)+setUserProfile({...})
缺任何一步,App 启动时的恢复逻辑都可能把用户重新送回商家端。
规律四:onShow 中刷新列表必须先重置分页
onShow() {
this.page = 1 // 必须
this.hasMore = true // 必须
this.list = [] // 必须
this.loadData()
}
规律五:多处批量替换后的必检项
# 检查三段式结构完整性
Select-String -Path "xxx.uvue" -Pattern "^<template>|^</template>|^<script|^</script>|^<style|^</style>"
# 检查关键 import 是否存在
Select-String -Path "xxx.uvue" -Pattern "import supa|type MemberLevel"
第二轮未解决问题(需后续跟进)
| 序号 | 问题 | 当前状态 | 建议 |
|---|---|---|---|
| P-01 | 消息中心/聊天功能结构未完善 | 页面存在但功能简陋,未实现会话列表/未读标记/订单关联 | 参考正规电商商家端重新设计消息架构 |
| P-02 | 扫码功能未实现 | scan.uvue 页面存在但功能为占位符 |
接入 uni.scanCode API |
| P-03 | 会员客户列表加载时出现"暂无客户"再显示数据(loading/empty 状态混淆) | 已知但未在本轮修复 | 添加 isLoading 状态,loading 中不显示 empty 状态 |
| P-04 | uni-app SDK AAR 依赖缺失(延续自第一轮 §5.7) | 涉及 uni_showModal 等 API 的 Kotlin 符号缺失 |
从 HBuilderX 本地资源包补充 AAR 到 app/libs/ |
| P-05 | getPushClientId 推送 CID 注册未实现(延续自第一轮 §5.12) | 待用正确的 UTS 签名重新实现 | 参考 §5.12 中的修复模板 |
第二轮记录更新:2026-04-21
第三轮消费者端安卓适配补充记录(2026-05-13)
本轮时间:2026-05-13
调试阶段:消费者端主路径页面安卓真机适配补充
背景:前两轮主要覆盖商家端编译、构建与运行问题;本轮补充处理消费者端首页主链路中的列表滚动与视觉不一致问题,重点是购物车、我的、我的订单三个页面。
第三轮问题总览
| 序号 | 问题名称 | 页面/模块 | 问题类型 | 是否已解决 | 备注 |
|---|---|---|---|---|---|
| R3-01 | 购物车页面无法上下滑动 | consumer/cart | 安卓布局 | ✅ 已解决 | 100vh + overflow:hidden + scroll-view height:0 组合失效 |
| R3-02 | 我的页面无法上下滑动 | consumer/my | 安卓布局 | ✅ 已解决 | 与购物车同源,scroll-view 未拿到明确高度 |
| R3-03 | 我的订单页面无法上下滑动 | consumer/orders | 安卓布局 | ✅ 已解决 | 已计算 viewportHeight,但未绑定到滚动容器 |
| R3-04 | 我的页面安卓配色与小程序结果不一致 | consumer/my | 安卓视觉 | ✅ 已解决 | 渐变与叠层卡片在 Android 原生渲染器中回退异常 |
R3-01/R3-02/R3-03:消费者端主列表页面无法上下滑动(根容器高度链断裂)
现象
pages/main/cart.uvue:购物车商品列表和推荐商品区域在安卓端无法下滑pages/main/profile.uvue:我的页面钱包、订单、猜你喜欢区域无法下滑pages/mall/consumer/orders.uvue:我的订单列表只能显示首屏内容,后续订单无法通过手势下滑查看
出现位置
d:\骅锋\mall\pages\main\cart.uvued:\骅锋\mall\pages\main\profile.uvued:\骅锋\mall\pages\mall\consumer\orders.uvue
问题类型
安卓端布局 / scroll-view 高度计算
根因分析
三个页面的结构本质一致:
/* ❌ 旧写法的共同模式 */
.page {
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-scroll {
flex: 1;
height: 0;
}
在小程序 / H5 中,这类写法通常仍能滚动,因为浏览器会把 flex: 1 和 height: 0 结合成可收缩的滚动区域;但在 uni-app x 的 Android 原生渲染链路中,scroll-view 必须拿到明确的像素高度。一旦父容器只给出 100vh、内部再靠 height: 0 收缩,原生布局引擎就可能把滚动区计算成非 scrollable 容器,最终表现为:
- 页面内容正常渲染
- 可见区域能显示首屏内容
- 手指上下滑动无响应
orders.uvue 的情况更隐蔽:源码里已经计算了 viewportHeight,但该值仅用于虚拟列表可视区估算,没有实际绑定到 scroll-view 容器,所以安卓端仍然拿不到真实可滚动高度。
修复方式
修复原则统一为:继续保留原有布局结构,但只在 Android 端为页面根容器和 scroll-view 注入明确像素高度,避免影响小程序现有表现。
购物车 / 我的页面修复模式
const pageWindowHeight =
systemInfo.windowHeight ?? systemInfo.screenHeight ?? 0;
// #ifdef APP-ANDROID
isAndroidApp.value = true;
// #endif
const pageStyle = computed((): string => {
if (!isAndroidApp.value) return "";
if (pageWindowHeight.value <= 0) return "";
return (
"height:" +
pageWindowHeight.value +
"px;min-height:" +
pageWindowHeight.value +
"px;"
);
});
const contentStyle = computed((): string => {
if (!isAndroidApp.value) return "";
const contentHeight = pageWindowHeight.value - fixedHeaderHeight;
return "height:" + contentHeight + "px;min-height:" + contentHeight + "px;";
});
模板中把样式显式绑定到页面容器和 scroll-view:
<view class="page" :style="pageStyle">
<scroll-view
class="content-scroll"
:style="contentStyle"
:scroll-y="true"
></scroll-view>
</view>
我的订单页面修复模式
orders.uvue 已有 viewportHeight,所以只需让这个值真正进入滚动容器:
pageWindowHeight.value =
systemInfo.windowHeight ?? systemInfo.screenHeight ?? 0;
const windowHeight = pageWindowHeight.value;
const calculatedViewport = windowHeight - statusBarHeight.value - 44 - 48;
viewportHeight.value =
calculatedViewport > 0 ? calculatedViewport : windowHeight;
// #ifdef APP-ANDROID
isAndroidApp.value = true;
// #endif
const ordersContentStyle = computed((): string => {
if (!isAndroidApp.value) return "";
if (viewportHeight.value <= 0) return "";
return (
"height:" +
viewportHeight.value +
"px;min-height:" +
viewportHeight.value +
"px;"
);
});
涉及文件
d:\骅锋\mall\pages\main\cart.uvued:\骅锋\mall\pages\main\profile.uvued:\骅锋\mall\pages\mall\consumer\orders.uvue
经验总结
规律:消费者端主列表页在 Android 上如果出现“首屏能显示、但完全不能滚动”,优先检查是否存在
height: 100vh + overflow: hidden + scroll-view height: 0/flex:1组合。对 uni-app x Android 来说,scroll-view 最稳妥的做法不是依赖 Web 风格的收缩布局,而是给出明确的像素高度。规范:有固定头部、筛选条、底部结算栏的页面,Android 端要么使用完整的
height: 100%高度链,要么直接通过windowHeight - 固定区域高度计算 scroll-view 像素高度;不要只把高度算在 JS 里而不绑定到模板。
R3-04:我的页面安卓端配色与小程序不一致(渐变与叠层卡片回退异常)
现象
pages/main/profile.uvue 在微信小程序中顶部是明显的红色渐变,会员条是深棕金色;但在安卓真机中,顶部和钱包会员区出现发白、发灰、层次减弱的问题,视觉结果与小程序明显不一致。
出现位置
d:\骅锋\mall\pages\main\profile.uvue
问题类型
安卓端视觉兼容 / CSS 渐变回退
根因分析
问题不在数据,而在样式实现方式:页面头部和会员条使用了复杂叠层视觉,核心依赖如下写法:
/* ❌ 旧写法 */
.jd-profile-top {
background: linear-gradient(180deg, #ff3b30 0%, #ff5a36 60%, #f5f5f5 100%);
}
.wallet-plus-ribbon {
background: linear-gradient(90deg, #5a3a21 0%, #75542f 52%, #a68049 100%);
}
在小程序端,这类渐变简写可以正常渲染;但在 Android 原生渲染器中,复杂渐变、卡片叠层和浅色蒙版叠加后,容易出现:
- 渐变层未按预期渲染
- 背景色回退到默认浅色
- 文本和图标看起来像“发白”“褪色”
这类问题不会报编译错误,属于纯渲染兼容差异。
修复方式
处理原则是:保留小程序端原视觉,同时为 Android 增加稳定的配色回退类。
步骤一:把单一 background 拆成 background-color + background-image
.jd-profile-top {
background-color: #ff5a36;
background-image: linear-gradient(
180deg,
#ff3b30 0%,
#ff5a36 60%,
#f5f5f5 100%
);
}
.wallet-plus-ribbon {
background-color: #8a6638;
background-image: linear-gradient(
90deg,
#5a3a21 0%,
#75542f 52%,
#a68049 100%
);
}
步骤二:只在 Android 端切换到稳定回退样式
<view
class="jd-profile-top"
:class="{ 'jd-profile-top-android': isAndroidApp }"
></view>
<view
class="wallet-plus-ribbon"
:class="{ 'wallet-plus-ribbon-android': isAndroidApp }"
></view>
.jd-profile-top-android {
background-image: none;
background-color: #ff5a36;
}
.wallet-plus-ribbon-android {
background-image: none;
background-color: #8a6638;
}
这样做的结果是:
- 小程序端继续保留原来的渐变视觉
- Android 端使用稳定纯色回退,不再出现大面积发白发灰
- 端间通过条件类隔离,不互相影响
涉及文件
d:\骅锋\mall\pages\main\profile.uvue
经验总结
规律:uni-app x Android 对复杂
linear-gradient、多层卡片叠色和透明蒙版的支持不如小程序稳定。遇到“同一份代码,小程序颜色正常、Android 发灰发白”的情况,应优先怀疑渐变和叠层渲染回退,而不是业务数据或主题变量。规范:复杂视觉区域采用“两层策略”最稳妥:默认样式保留渐变,小程序/H5 使用完整视觉;Android 端通过
#ifdef APP-ANDROID或运行时isAndroidApp类名切换到纯色回退,保证颜色稳定。
第三轮通用修复规律总结
规律一:消费者端主链路页面的 scroll-view 不要依赖浏览器式自适应高度
| 场景 | 更稳妥的 Android 写法 |
|---|---|
| 页面只有一个滚动主体 | 根容器显式高度 + scroll-view 显式高度 |
| 页面有固定头部 / Tab / 筛选条 | windowHeight - 固定区域高度 计算出内容区像素高度 |
| 页面内部有虚拟列表 | 虚拟列表 viewportHeight 既用于计算,也必须真正绑定到 scroll-view |
规律二:端差异优先做“条件运行”而不是直接改公共样式
// #ifdef APP-ANDROID
isAndroidApp.value = true;
// #endif
通过端标记切换模板类名或样式字符串,可以保证:
- Android 端拿到稳定布局/配色
- 小程序端保持原有视觉和交互
- 后续排查时能快速判断某个问题是不是 Android 专用回退逻辑触发
规律三:视觉兼容优先降级到“可控纯色”,不要硬扛复杂渐变
当页面关键区域承担品牌色表达时,Android 端出现颜色失真,优先级应是:
- 先保证颜色正确
- 再保证层次接近
- 最后才追求和小程序完全一致的渐变细节
原因很简单:用户首先感知的是“颜色对不对”,其次才是“渐变够不够精致”。
第三轮记录更新:2026-05-13
13. Delivery 端地址空白问题(PostgreSQL 函数层面)
记录时间:2026-05-28 涉及表:
ec_care_tasks、hss_service_orders涉及函数:public.delivery_build_order_json(...)影响端:医疗-delivery(配送端订单详情页、路线页)
13.1 问题现象
Consumer 端下单后,订单详情页地址显示正常。但 Delivery 端的订单详情页(detail.uvue)和路线页(route.uvue)中,order.address、order.addressDetail 等地址字段始终为空白。
同时:
elderGender(性别)和elderAge(年龄)显示正常- 只有地址相关字段(
address、addressSummary、addressDetail、fullAddress、latitude、longitude)为空
13.2 数据层背景
本项目存在双订单存储:
| 表 | 说明 | 地址字段 |
|---|---|---|
ec_care_tasks |
Care 路径(新) | address_snapshot(JSONB)、address_snapshot_json(JSONB) |
hss_service_orders |
Legacy 路径(旧) | address_snapshot_json(JSONB,NOT NULL DEFAULT '{}'::jsonb) |
Consumer 端创建订单时,根据 useAddressSnapshot 参数决定写入 address_snapshot 还是 address_snapshot_json。ec_care_tasks 表两列同时存在,hss_service_orders 表只有 address_snapshot_json 列。
Delivery 端通过 RPC 调用 rpc_delivery_order_list / rpc_delivery_dashboard / rpc_delivery_order_detail 获取订单,这些 RPC 内部统一调用 delivery_build_order_json 函数来构建返回的 JSON 结构。
13.3 排查过程
第一轮:怀疑空对象阻断 fallback
最初怀疑 address_snapshot_json 列存储了空对象 '{}'::jsonb,导致 COALESCE 将其视为有效值而跳过 fallback 到 address_snapshot。
尝试修复:执行 20260528_fix_address_fallback.sql,在 v_address 的 COALESCE 中加入 NULLIF 排除空对象:
v_address JSONB := COALESCE(
NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb),
NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb),
NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb),
NULLIF(p_raw -> 'address_snapshot', 'null'::jsonb),
'{}'::jsonb
);
结果:执行后 Delivery 端地址仍然空白,修复未生效。
第二轮:诊断脚本定位
创建并执行诊断脚本 20260528_debug_address.sql,三个诊断查询:
诊断1:确认函数版本
SELECT proname,
CASE WHEN prosrc LIKE '%NULLIF(p_raw -> ''address_snapshot_json'', ''{}''::jsonb)%' THEN 'FIXED' ... END AS address_fallback_status,
CASE WHEN prosrc LIKE '%|| jsonb_build_object%' THEN 'SPLIT' ELSE 'SINGLE_BLOCK' END AS function_structure
FROM pg_proc WHERE proname = 'delivery_build_order_json';
结果:FIXED (含 NULLIF 排除空对象) + SPLIT (小块拼接)。说明函数版本已经是最新修复版。
诊断2:查看订单地址数据状态
SELECT id, task_no,
COALESCE(address_snapshot_json::TEXT, 'NULL') AS addr_json_raw,
COALESCE(address_snapshot::TEXT, 'NULL') AS addr_snap_raw
FROM public.ec_care_tasks ORDER BY created_at DESC LIMIT 5;
结果:address_snapshot_json 全部是 NULL,address_snapshot 有完整数据(包含 fullAddress、detailAddress、latitude、longitude 等字段)。
诊断3:直接测试函数输出
SELECT delivery_build_order_json(to_jsonb(t), '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care') ->> 'address' AS test_address
FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1;
结果:test_address = 空,但 test_gender = "女"、test_age = 78(正常)。
第三轮:逐步拆解 COALESCE
为了确认 COALESCE + NULLIF 的行为,执行逐步测试:
WITH test_data AS (SELECT to_jsonb(t) AS p_raw FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1)
SELECT
p_raw -> 'address_snapshot_json' AS step1_raw_json_col, -- NULL
NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb) AS step2, -- NULL
NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb) AS step3, -- NULL
p_raw -> 'address_snapshot' AS step4_raw_snap_col, -- 有完整数据
NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb) AS step5, -- 有完整数据
COALESCE(
NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb),
NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb),
NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb),
NULLIF(p_raw -> 'address_snapshot', 'null'::jsonb),
'{}'::jsonb
) AS final_v_address -- NULL ❌
FROM test_data;
关键发现:
step5(单独计算的NULLIF)= 有完整数据的 JSONB 对象final_v_address(同样的表达式嵌套在COALESCE中)= NULL
这说明 COALESCE(NULLIF(...)) 的组合在 PostgreSQL 中对 JSONB 类型存在不可预期的行为。虽然每个子表达式单独执行都正确,但嵌套在 COALESCE 内部时,v_address 最终变成了 NULL。
第四轮:确认 to_jsonb 直接访问正常
为了排除 to_jsonb(t) 本身的问题,直接测试:
SELECT
to_jsonb(t) -> 'address_snapshot' ->> 'fullAddress' AS test_fullAddress,
to_jsonb(t) -> 'address_snapshot' ->> 'detailAddress' AS test_detail
FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1;
结果:test_fullAddress = "广东省梅州市梅县区扶大镇新城科技园景逸花园 骅锋科技",test_detail = "骅锋科技"。
结论:数据完全正常,问题只在函数内部的 v_address 赋值逻辑。
13.4 根因分析
PostgreSQL 的 COALESCE + NULLIF 组合在处理 JSONB 类型时存在隐式类型推断陷阱:
p_raw -> 'address_snapshot_json'返回 SQLNULLNULLIF(NULL, '{}'::jsonb)返回 SQLNULLNULLIF(NULL, 'null'::jsonb)返回 SQLNULLp_raw -> 'address_snapshot'返回有数据的 JSONB 对象NULLIF(对象, '{}'::jsonb)应该返回该对象
但当这些表达式嵌套在 COALESCE 中时,COALESCE 的类型统一推断机制与 NULLIF 的返回值类型产生了冲突,导致整个表达式最终返回 NULL 而非预期的 JSONB 对象。
注:此行为在 PostgreSQL 文档中无明确定义,属于 JSONB 类型与
COALESCE/NULLIF交互的边界情况。同样的逻辑对TEXT、INTEGER等基础类型工作正常。
13.5 修复方案
核心改动:将 v_address 的赋值从 COALESCE(NULLIF(...)) 改为 CASE WHEN jsonb_typeof(...) = 'object',完全绕过 COALESCE + NULLIF 的陷阱。
-- 旧写法(有bug)
v_address JSONB := COALESCE(
NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb),
NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb),
NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb),
NULLIF(p_raw -> 'address_snapshot', 'null'::jsonb),
'{}'::jsonb
);
-- 新写法(可靠)
v_address JSONB := CASE
WHEN jsonb_typeof(p_raw -> 'address_snapshot_json') = 'object'
AND p_raw -> 'address_snapshot_json' != '{}'::jsonb
AND p_raw -> 'address_snapshot_json' != 'null'::jsonb
THEN p_raw -> 'address_snapshot_json'
WHEN jsonb_typeof(p_raw -> 'address_snapshot') = 'object'
AND p_raw -> 'address_snapshot' != '{}'::jsonb
AND p_raw -> 'address_snapshot' != 'null'::jsonb
THEN p_raw -> 'address_snapshot'
ELSE '{}'::jsonb
END;
修复文件:mall_sql/migrations/20260528_fix_address_v2.sql
验证:
SELECT delivery_build_order_json(to_jsonb(t), '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care') ->> 'address' AS test_address
FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1;
-- 结果:"广东省梅州市梅县区扶大镇新城科技园景逸花园 骅锋科技" ✅
13.6 经验教训
| # | 教训 |
|---|---|
| 1 | 不要用 COALESCE(NULLIF(...)) 处理 JSONB 类型。PostgreSQL 中 JSONB 与 COALESCE/NULLIF 的组合行为不稳定,应改用 CASE WHEN + jsonb_typeof() |
| 2 | 数据层诊断优先于前端排查。当 Delivery 端显示空白而 Consumer 端正常时,首先应检查 RPC 函数对同一条数据的实际输出,而不是怀疑前端渲染逻辑 |
| 3 | 逐步拆解复杂表达式。step5 单独执行正确但 final_v_address 为 NULL,说明问题出在表达式嵌套层面,而非数据本身 |
| 4 | 保留诊断脚本归档。20260528_debug_address.sql 可作为后续类似问题的排查模板 |
第四轮记录更新:2026-05-28