Files
medical-mall/docs/android_debug_full_fix_guide.md

112 KiB
Raw Permalink Blame History

安卓端调试成功全过程问题总结与修复指南

文档类型:经验沉淀文档(非简单周报)
维护人:黄镇堡
最后更新2026-04-20
项目:智慧医养 malluni-app x
分支huangzhenbao-admin
AppID__UNI__81482FF


目录

  1. 文档说明
  2. 时间范围与排查范围
  3. 总体结论
  4. 生成本地打包APP资源阶段——UTS 语法问题
  5. Run 到 Android 端时的报错与修复全过程
  6. JS / UTS / Kotlin 语义差异总结
  7. Android 适配与页面运行问题总结
  8. 额外发现的重要隐藏问题
  9. 面向后续开发的编码规范清单
  10. 面向后续排查的标准流程清单
  11. 关键文件与关键改动索引
  12. 结语

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 xHBuilderX 5.07
前端语言 UTSTypeScript 子集,编译为 Kotlin
后端 SupabasePostgreSQL + Edge Functions
Android 构建 Android Studio Ladybug + assembleDebug
Android 最低版本 API 21Android 5.0

2. 时间范围与排查范围

2.1 时间范围

  • 开始2026-04-13开始尝试 Android 本地打包)
  • 完成2026-04-20merchant 端能够在 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.jsonmanifest.jsonfix_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 === bobj.field)在 UTS/Kotlin 中要么报编译错误,要么产生意料之外的运行时行为。
  • CSS 在 Web 浏览器中自动降级处理,在 Android 原生渲染器中不行,无效属性直接被忽略或崩溃。

3.2 三阶段问题分布

┌────────────────────────────────────────────────────────────────────────┐
│  阶段AHBuilderX "生成本地打包APP资源"                                  │
│  工具HBuilderX 5.07 UTS编译器                                         │
│  产物:.kt 文件到 unpackage/dist/build/app-android/                     │
│  问题UTS 语法错误13类编译直接失败                                  │
├────────────────────────────────────────────────────────────────────────┤
│  阶段BAndroid Studio assembleDebug                                    │
│  工具Gradle + Kotlin 编译器                                            │
│  产物app-debug.apk                                                    │
│  问题Kotlin 符号 unresolveduni SDK AAR缺失、=== 运行时语义错误      │
├────────────────────────────────────────────────────────────────────────┤
│  阶段CAPK 安装到真机/模拟器运行                                          │
│  工具Android 真机                                                      │
│  问题布局截断、顶部遮挡、双层TabBar、跳转白屏、无法滚动                   │
└────────────────────────────────────────────────────────────────────────┘

3.3 问题数量统计

阶段 问题分类数 已解决 备注
AUTS编译 13类 13类
BAndroid构建 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.txterrors_round2.txt


4.1 非严格布尔条件(最常见)

错误信息

condition type mismatch: String but Boolean expected
condition type mismatch: Int? but Boolean expected

根因
JavaScript 有 truthy/falsy 机制空字符串、0、null 均为 falsyUTS/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.uvueindex.uvuemessages.uvuefinance.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\报错信息.txtbuild_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 问题1main.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 问题5onLoad 参数 Android 端取不到值

现象:登录页收不到 URL 中的 redirect 参数,登录后无法跳回原页面。

根因:旧写法通过 getCurrentPages() 读取 options在 Android 上 optionsUTSJSONObject,点访问语法无效:

// 错误写法
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 问题6Android Studio Kotlin 符号 unresolveduni 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 问题7BOMByte Order Mark字符污染 Kotlin 文件

现象:某些 .kt 文件第一行出现 锘? 乱码前缀Kotlin 编译器报错:

error: expecting a top level declaration

根因Windows 环境下,某些编辑器或脚本保存文件时使用 UTF-8 with BOMBOM 字节(\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 问题8fix_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 问题9login.uvue 深层业务逻辑重构(多问题叠加案例)

背景login.uvue 是整个 merchant 流程的入口,其中的 parseRoleData 函数是 UTS 改造最复杂的单一案例——它在一个函数内叠加了 4 类 UTS 语法问题。

问题叠加清单

# JS 写法 UTS 错误类型 修复
1 参数类型 any | null 编译器无法推断数组操作 定义 ParseRoleInput 联合类型
2 dataArray.length > 0any 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 问题10getPushClientId 回调因多重 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.clientidres.cid res 为 UTSJSONObject不支持点访问属性
typeof res === 'string' typeof 是 JS 运行时操作符UTS/Kotlin 不支持
res && (...) && 要求左侧是 BooleanUTSJSONObject 不是 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([...]) 逐个 awaitUTS不完全支持

6.10 typeof 操作符在 UTS 中不可用

背景login.uvue 中因为使用了 typeof res === 'string' 导致整段推送 CID 代码无法编译(见 §5.12)。

平台 运行时类型判断
JavaScript typeof x === 'string'x instanceof Array
UTS编译到Kotlin x is StringKotlin 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.uvuegrowth.uvuemessages.uvuefinance.uvueorders.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-columnscroll-view 使用 flex: 1,禁止用 min-height: 100%


7.2 顶部内容被状态栏遮挡

页面:所有 29 个 navigationStyle: custom 的 merchant 页面
现象:顶部导航条贴着屏幕顶端,内容被系统状态栏(时间、电量区域)遮住约 2040px。

根因顶部安全区代码用条件编译包裹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.uvueorders.uvuemessages.uvuegrowth.uvueprofile.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.uvuegrowth.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 xAndroid 渲染器) column(垂直排列)— 与 Web 相反

如果 CSS 没有显式声明 flex-directionAndroid 实际是 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: rowcolumn,不能依赖平台默认值。


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全面屏设备为 2040px。单纯使用 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.txt50MB全是噪音无参考价值

发现过程:尝试用独立 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\xBFKotlin 编译器无法识别。

规范保存文件统一使用 UTF-8 无 BOM 编码。用 .NET File.WriteAllText() 写文件默认无 BOM是安全的。


8.4 any 类型掩盖了大量潜在的 Android 运行时错误

发现:项目中大量 as anyas any[] 在 HBuilderX 的宽松模式下通过编译,但在 Android 严格模式下全部需要改成具体类型。any 是 Android 适配的最大技术债。

规范新代码禁止使用 any;旧代码中的 any 是 Android 适配的优先改造目标。


8.5 AkReq 数据处理链路:从 request 到 UTSJSONObject 的完整类型模式

背景:本项目使用 AkReq(封装自 uni_modules/ak-req/index.uts)发起请求,但无论用 AkReq 还是 uni.requestAndroid 端的响应数据处理链路必须遵循严格的类型模式,否则会在编译或运行时报类型错误。

完整数据流模式

// 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.uvuesessionUser 来自 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 anyany[];改用 UTSJSONObjectArray<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;类型判断依赖编译期类型或 Kotlin is 运算符
  • 数值转换统一使用 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: rowcolumn不能依赖默认值Android 默认 columnWeb 默认 row
  • 底部安全区同时写 height: env(safe-area-inset-bottom)min-height 兜底
  • scroll-into-view 绑定的目标 ID 只能含字母、数字、下划线,且必须以字母开头

9.3 组件与事件规范

  • 事件处理器函数接收基础类型(stringnumberboolean),不接收联合类型
  • 复杂对象在模板层解构取字段值,传给事件函数
  • @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 SDKuni_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 源代码关键改动(5ab4789df99bd5a7

文件 主要改动 所属问题
pages/user/login.uvue 3处跳转改为 merchant/indexonLoad参数改用 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 改接 stringsafe-bottom 减小 §7.1, §7.9, §7.4
pages/mall/merchant/profile.uvue 删 MerchantTabBarmerchantId 判空改为 !== ''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。

三个最重要的认知:

  1. 类型是真实的string 不能当 boolean 用,null 不会隐式转换,any 是技术债
  2. === 是陷阱:它不产生编译错误,但在 Android 上产生隐蔽的逻辑错误
  3. 布局需要明确高度Android 原生渲染不支持从内容反向推算父元素高度

12.2 最值得团队反复看的 10 条规则

  1. ===!== 在 Android 是引用比较,一律改用 ==!=
  2. if 条件只接受 Booleanif (str) 在 UTS 中是编译错误
  3. UTSJSONObject 的字段必须用 .getString() 等方法访问,不能点访问
  4. any[] 必须改为 Array<UTSJSONObject>as any 是所有 Android 问题的根源
  5. 页面根容器用 height: 100% + flex-columnscroll-view 用 flex: 1,不用 min-height
  6. 状态栏 padding 用 var(--status-bar-height),不要包在 #ifdef MP-WEIXIN
  7. 系统 TabBar 和自定义 TabBar 不能并存Android 端用系统 TabBar
  8. 每次 HBuilderX 生成 .kt 文件后必须运行 fix_kt_files.ps1
  9. onLoad 参数取字段用 UTSJSONObject.getString(),不用 currentPage.options.field
  10. 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.uvueonShow 生命周期 + loadProducts() 方法

问题类型

状态管理 / 异步加载

根因分析

onShow 直接调用 loadProducts(),但没有先重置 page=1products=[]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=1hasMore=truelist=[]。删除/更新后的刷新也要重置,而不是继续从当前页追加。


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: hiddenscroll-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.uvue
  • d:\骅锋\mall\pages\mall\merchant\product-edit.uvue
  • d:\骅锋\mall\pages\mall\merchant\shop-edit.uvue
  • d:\骅锋\mall\pages\mall\merchant\product-detail.uvue
  • d:\骅锋\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.uvue
  • pages/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.uvue
  • d:\骅锋\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: falsependingDeleteId: ''


R2-11删除等级无效uni.showModal 回调在 Kotlin 中不执行)

现象:点击"删除",确认弹窗出现(uni.showModal),点击"确认",什么都没发生,等级没有被删除。

根因

// ❌ 旧写法
deleteLevel(id: string) {
  uni.showModal({
    success: (res: UniShowModalResult): void => {
      if (res.confirm === true) {
        void this._doDeleteLevel(id)  // ← 这里有两个问题
      }
    }
  })
}

两个叠加问题

  1. this 捕获失败uni.showModalsuccess 回调是一个 void lambdaUTS 编译为 Kotlin 时会变成跨线程的异步回调Kotlin 协程中的 suspend 上下文切换),this 引用在回调执行时可能已失效。

  2. void this._doDeleteLevel(id) 不可靠:在非 suspendvoid lambda 中调用 async 方法(编译后是 Kotlin suspend funUTS 不保证 .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: hiddenAndroid 原生渲染器不约束 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-13settings.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.showModalvoid 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-14App.uvue checkExistingSession 会把用户重新拉回商家端

根因:即使 settings.uvue 的退出成功跳到了登录页,下次 App 冷启动时,App.uvuecheckExistingSession() 还是会把用户送回商家端。

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 = nullsession 已清除)
  • uni.getStorageSync('user_id') = nullstorage 已清除)
  • 用户停留在登录页,不再被拉回

涉及文件

  • 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 用户业务 IDApp.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 的执行不被保证。

退出登录的完整清理清单tokenaccess+refresh+expires+ 用户信息 + 商家 ID + 首页缓存 + 角色缓存 + store 内存态,缺一不可。


R2-15members.uvue 编译失败(缺少 import supa 和 type MemberLevel

现象

HBuilderX 报编译错误Android Studio Kotlin 编译报大量 Unresolved reference: supaUnresolved reference: MemberLevelUnresolved reference: id/name/discount_rate 等。

根因

在一次多文件修复操作中,向 members.uvue<script> 区域注入了新代码,但同时不小心把原有的 import supatype 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-16members.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

规律三:退出登录必须三清

  1. 清 Supabase 内存 sessionawait supa.signOut()
  2. 清所有 storage keytoken × 3 + user_id + 商家相关 key × 4
  3. 清 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.uvue
  • d:\骅锋\mall\pages\main\profile.uvue
  • d:\骅锋\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: 1height: 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.uvue
  • d:\骅锋\mall\pages\main\profile.uvue
  • d:\骅锋\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

通过端标记切换模板类名或样式字符串,可以保证:

  1. Android 端拿到稳定布局/配色
  2. 小程序端保持原有视觉和交互
  3. 后续排查时能快速判断某个问题是不是 Android 专用回退逻辑触发

规律三:视觉兼容优先降级到“可控纯色”,不要硬扛复杂渐变

当页面关键区域承担品牌色表达时Android 端出现颜色失真,优先级应是:

  1. 先保证颜色正确
  2. 再保证层次接近
  3. 最后才追求和小程序完全一致的渐变细节

原因很简单:用户首先感知的是“颜色对不对”,其次才是“渐变够不够精致”。


第三轮记录更新2026-05-13


13. Delivery 端地址空白问题PostgreSQL 函数层面)

记录时间2026-05-28 涉及表ec_care_taskshss_service_orders 涉及函数public.delivery_build_order_json(...) 影响端:医疗-delivery配送端订单详情页、路线页


13.1 问题现象

Consumer 端下单后,订单详情页地址显示正常。但 Delivery 端的订单详情页(detail.uvue)和路线页(route.uvue)中,order.addressorder.addressDetail 等地址字段始终为空白。

同时:

  • elderGender(性别)和 elderAge(年龄)显示正常
  • 只有地址相关字段(addressaddressSummaryaddressDetailfullAddresslatitudelongitude)为空

13.2 数据层背景

本项目存在双订单存储

说明 地址字段
ec_care_tasks Care 路径(新) address_snapshotJSONBaddress_snapshot_jsonJSONB
hss_service_orders Legacy 路径(旧) address_snapshot_jsonJSONBNOT NULL DEFAULT '{}'::jsonb

Consumer 端创建订单时,根据 useAddressSnapshot 参数决定写入 address_snapshot 还是 address_snapshot_jsonec_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_addressCOALESCE 中加入 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 全部是 NULLaddress_snapshot 有完整数据(包含 fullAddressdetailAddresslatitudelongitude 等字段)。

诊断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 类型时存在隐式类型推断陷阱

  1. p_raw -> 'address_snapshot_json' 返回 SQL NULL
  2. NULLIF(NULL, '{}'::jsonb) 返回 SQL NULL
  3. NULLIF(NULL, 'null'::jsonb) 返回 SQL NULL
  4. p_raw -> 'address_snapshot' 返回有数据的 JSONB 对象
  5. NULLIF(对象, '{}'::jsonb) 应该返回该对象

但当这些表达式嵌套在 COALESCE 中时,COALESCE 的类型统一推断机制与 NULLIF 的返回值类型产生了冲突,导致整个表达式最终返回 NULL 而非预期的 JSONB 对象。

注:此行为在 PostgreSQL 文档中无明确定义,属于 JSONB 类型与 COALESCE/NULLIF 交互的边界情况。同样的逻辑对 TEXTINTEGER 等基础类型工作正常。


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