Files
medical-mall/docs/android_debug_full_fix_guide.md

2981 lines
112 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 安卓端调试成功全过程问题总结与修复指南
> **文档类型**:经验沉淀文档(非简单周报)
> **维护人**:黄镇堡
> **最后更新**2026-04-20
> **项目**:智慧医养 malluni-app x
> **分支**`huangzhenbao-admin`
> **AppID**`__UNI__81482FF`
---
## 目录
1. [文档说明](#1-文档说明)
2. [时间范围与排查范围](#2-时间范围与排查范围)
3. [总体结论](#3-总体结论)
4. [生成本地打包APP资源阶段——UTS 语法问题](#4-生成本地打包app资源阶段uts-语法问题)
5. [Run 到 Android 端时的报错与修复全过程](#5-run-到-android-端时的报错与修复全过程)
6. [JS / UTS / Kotlin 语义差异总结](#6-js--uts--kotlin-语义差异总结)
7. [Android 适配与页面运行问题总结](#7-android-适配与页面运行问题总结)
8. [额外发现的重要隐藏问题](#8-额外发现的重要隐藏问题)
9. [面向后续开发的编码规范清单](#9-面向后续开发的编码规范清单)
10. [面向后续排查的标准流程清单](#10-面向后续排查的标准流程清单)
11. [关键文件与关键改动索引](#11-关键文件与关键改动索引)
12. [结语](#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.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 三阶段问题分布
```
┌────────────────────────────────────────────────────────────────────────┐
│ 阶段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.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 均为 falsyUTS/Kotlin 的 `if` 条件**必须是 Boolean 类型**,不接受其他类型隐式转换。
**错误写法 → 修复写法**
```typescript
// ❌ 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。
```typescript
// ❌ 错误写法
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 端数组必须有明确的元素类型。
```typescript
// ❌ 错误写法
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` 不是普通对象,字段不能用点访问,必须用方法访问。
```typescript
// ❌ 错误写法
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` 不支持内联多行逻辑,只能调用命名方法。
```html
<!-- ❌ 错误写法 -->
<view @click="() => { doA(); doB() }">
<!-- ✅ 修复写法 -->
<view @click="handleClick"></view
></view>
```
```typescript
// 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` 工具函数统一处理:
```typescript
// 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) ?? "";
}
```
模板使用:
```html
<picker @change="(e) => { selectedIdx = getPickerIdx(e) }"></picker>
```
---
### 4.8 对象字面量未声明命名类型
**错误信息**
```
smart cast to 'String' is impossible
unresolved reference 'link'
```
**根因**
UTS 的对象字面量需要有已声明的 `type` 类型定义否则编译器无法推断字段名smart cast 失败。
```typescript
// ❌ 错误写法
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` 变量。
```typescript
// ❌ 错误写法
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 没有对应的直接映射。
```typescript
// ❌ 错误写法
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——将事件处理器改为只接收基础类型参数在模板层完成字段提取
```html
<!-- 模板:在调用处提取字段 -->
<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?` 类型不能直接进行字符串运算,需要先用 `?? ''` 确保非空。
```typescript
// ❌ 错误写法
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`),多级嵌套时编译器无法推断中间类型。
```typescript
// ❌ 错误写法
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` 生命周期函数签名简洁无歧义:
```typescript
// 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
// 生成的 Kotlin 代码(错误)
if (loginType.value === 1) { ... } // Kotlin: 引用比较Number(1) !== 1
// fix_kt_files.ps1 修复后
if (loginType.value == 1) { ... } // Kotlin: 值比较,正确
```
**修复**`fix_kt_files.ps1` 全局替换:
```powershell
$c = $c -replace ' === ', ' == '
$c = $c -replace ' !== ', ' != '
```
> ⚠️ **这是整个 Android 调试过程中最隐蔽的 bug**。它不产生编译错误,只产生逻辑错误,且很难通过代码审查发现。每次 HBuilderX 重新生成 `.kt` 文件后都必须重新运行此脚本。
---
### 5.5 问题4登录后跳转到不存在的路由白屏
**现象**商家账号登录成功Android 页面停在白屏,不跳转。
**根因**`login.uvue` 中 3 处跳转硬编码到 `admin/homePage/index`,该路由在精简后的 Android `pages.json` 中不存在:
```typescript
// 错误写法3处
uni.reLaunch({ url: "/pages/mall/admin/homePage/index" });
```
**修复**:统一改为 Android 端实际存在的路由:
```typescript
uni.reLaunch({ url: "/pages/mall/merchant/index" });
```
---
### 5.6 问题5`onLoad` 参数 Android 端取不到值
**现象**:登录页收不到 URL 中的 `redirect` 参数,登录后无法跳回原页面。
**根因**:旧写法通过 `getCurrentPages()` 读取 options在 Android 上 `options``UTSJSONObject`,点访问语法无效:
```typescript
// 错误写法
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()`
```typescript
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` 中声明:
```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 问题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 完整修复内容
```powershell
# 修复内容一览:
# 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 > 0``any` 上 | unresolved reference | 先 `Array.isArray()``as Array<UTSJSONObject>` |
| 3 | `obj.getString('role')` 结果直接参与 `===` 比较 | `String?` 与字面量引用比较永远 false | `?? ''` 兜底 + 改用 `==` |
| 4 | `id != null` 空字符串漏判 | `getString` 返回 `""` 而非 null | 改为 `id !== ''` |
**修复前后对比**
```typescript
// ❌ 修复前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;
};
```
**同一文件中的其它修复点**(一并记录,避免遗漏)
```typescript
// 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 的整段代码被删除,推送功能暂时失效。
**被删除的原始代码**
```typescript
// ❌ 以下代码在 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 && (...)` | `&&` 要求左侧是 BooleanUTSJSONObject 不是 Boolean |
| 回调 `async (res: any)` | UTS 对异步回调有严格签名要求,不允许 `any` 参数 |
**后续正确实现方式(待开发)**
```typescript
// ✅ 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 |
```typescript
// ❌ UTS 中 || 不能用于字符串兜底
const name = obj.getString("name") || "匿名"; // 编译错误
// ✅ 使用 ??
const name = obj.getString("name") ?? "匿名";
```
---
### 6.4 短路运算符 `&&` 的限制
```typescript
// ❌ 左侧不是 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 类型推导差异
```typescript
// ❌ 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')` | 不推荐,改用判空后调用 |
```typescript
// ❌ 多级可选链
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 String`Kotlin `is` 运算符)或通过类型系统静态保证 |
```typescript
// ❌ 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`
```typescript
// 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`
**现象**:列表只显示一小部分,无法向下滚动,底部内容不可达。
**根因**
```css
/* 旧写法 */
.merchant-container {
min-height: 100%;
} /* ← 问题根源 */
.main-scroll {
height: 100%;
} /* ← 无有效父高度,=0 */
```
`min-height: 100%` 在 Web 中靠内容撑高,子元素 `height: 100%` 继承的是内容高度而非视口高度。在 Android 原生渲染中,布局计算一次性完成,`min-height` 不给子元素提供明确高度,`scroll-view` 拿到的可滚动高度为 0。
**修复**
```css
/* 修复后 */
.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 页面
**现象**:顶部导航条贴着屏幕顶端,内容被系统状态栏(时间、电量区域)遮住约 2040px。
**根因**顶部安全区代码用条件编译包裹Android 编译时被排除:
```html
<!-- 旧写法Android 编译时整个块消失 -->
<!-- #ifdef MP-WEIXIN -->
<view class="mp-tab-navbar">...</view>
<!-- #endif -->
```
**修复**:移除 `<!-- #ifdef MP-WEIXIN -->``<!-- #endif -->` 包裹,让顶部安全区对所有平台生效:
```html
<!-- 修复后:所有平台都渲染 -->
<view class="mp-tab-navbar">...</view>
```
```css
.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>` 自定义组件:
```html
<!-- 页面内还有这个 ← 与系统TabBar并存 -->
<merchant-tab-bar :current="0"></merchant-tab-bar>
```
**修复**:从 5 个主 Tab 页删除自定义 TabBar 相关代码:
```html
<!-- 删除 template 中 -->
<merchant-tab-bar :current="0"></merchant-tab-bar>
```
```typescript
// 删除 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 移除后空间成为多余空白。
**修复**
```css
/* 旧 */
.safe-bottom {
height: 160rpx;
}
/* 新 */
.safe-bottom {
height: 20rpx;
}
```
并新增"到底提示"作为视觉终止符:
```html
<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。
**修复**
```typescript
// 旧
const statusBarH = sysInfo.statusBarHeight; // 可能 null
// 新
const statusBarH = sysInfo.statusBarHeight ?? 20;
```
---
### 7.9 事件处理器接收联合类型导致 smart cast 失败
**页面**`growth.uvue`
**现象**:点击列表项无响应。
**根因**:事件处理函数接收 `TipType | GuideType | CourseType` 联合类型Android UTS 无法 smart cast字段访问失败。
**修复**:模板层提取字段,函数接收基础类型:
```html
<!---->
@click="viewDetail(tip)"
<!---->
@click="viewDetail(tip.link)"
```
```typescript
// 函数只接收 string
viewDetail(link: string) {
if (link.length > 0) { uni.navigateTo({ url: link }) }
}
```
---
### 7.10 Android 不支持的 CSS 属性
**现象**:卡片无阴影,渐变色显示异常。
**根因**`box-shadow` 和多停止点 `linear-gradient` 在 Android 原生渲染层不支持。
**修复**
```css
/* 移除 box-shadow */
/* 渐变去掉百分比停止点 */
/* 旧 */
background: linear-gradient(135deg, #ff6b35 0%, #ff8c5a 100%);
/* 新 */
background: linear-gradient(135deg, #ff6b35, #ff8c5a);
```
**规范****禁用 `box-shadow`;渐变只用两端无停止点写法。**
---
### 7.11 字符串空值判断不兼容
**页面**`profile.uvue`
**现象**`merchantId` 未赋值时仍发起了数据请求。
```typescript
// 旧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-direction`Android 实际是 `column`,而 Web/小程序 是 `row`
**修复**:需要水平排列的容器,必须**显式声明** `flex-direction: row`
```css
/* ❌ 省略 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 前对特殊字符做过滤:
```typescript
// 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` 中的做法):
```html
<!-- 底部安全区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 要求回调签名必须与接口完全匹配:
```typescript
// 旧(报 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 端的响应数据处理链路必须遵循严格的类型模式,否则会在编译或运行时报类型错误。
**完整数据流模式**
```typescript
// 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 调用可选链 `?.` + 方法调用会编译失败。
```typescript
// ❌ 旧写法(两个问题:?. 用在 any 类型上 + getString 在可空链上)
let sessionUid = sessionUser?.getString("id") ?? "";
// ✅ 修复写法(先判 null再调用
let sessionUid: string =
sessionUser != null ? (sessionUser.getString("id") ?? "") : "";
```
**模式推广**:所有从 `uni.getStorageSync()` 读出的 `UTSJSONObject` 类型数据,必须先做 `!= null` 判断,再用方法访问字段,不能用可选链 `?.` 直接调用 UTSJSONObject 的访问方法。
```typescript
// 通用模式
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`;类型判断依赖编译期类型或 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: 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 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 源代码关键改动(`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 查看命令
```powershell
# 查看基线到最终的所有源码改动
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` 条件只接受 Boolean`if (str)` 在 UTS 中是编译错误**
3. **`UTSJSONObject` 的字段必须用 `.getString()` 等方法访问,不能点访问**
4. **`any[]` 必须改为 `Array<UTSJSONObject>``as any` 是所有 Android 问题的根源**
5. **页面根容器用 `height: 100% + flex-column`scroll-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.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()` 之前先重置分页状态:
```typescript
onShow() {
// 每次页面显示都要重置,防止"3+3=6"数据累加
this.page = 1
this.hasMore = true
this.products = []
this.loadProducts()
},
```
同步修复 `_doDeleteProduct()` 删除商品后的刷新逻辑(同样需要重置再刷):
```typescript
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` 布尔属性:
```html
<!-- ❌ 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"`
```html
<!-- ✅ Android 端正确写法 -->
<scroll-view direction="vertical" class="list-container"></scroll-view>
```
同时确保父容器有 `overflow: hidden`scroll-view 本身有 `flex: 1`
```css
.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` 在安卓端非根视图中不可靠**
```css
/* ❌ 旧写法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%` 导致高度链断裂**
```css
/* ❌ 旧写法 */
.page {
min-height: 100%;
}
```
`min-height: 100%` 给元素设置了"至少等于视口高度"的约束但没有给子元素提供可继承的明确高度。Android 原生布局计算时,子 scroll-view 的 `flex: 1` 无法找到参照高度,实际高度为 0内容溢出到视口之外造成白屏。
### 修复方式
**统一修复模式(所有含提交栏 / 操作栏的页面)**
```css
/* ✅ 正确的 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 的修复**(无提交栏,只有高度链问题):
```css
.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**:改为动态标题:
```html
<MerchantNavBar :title="isEdit ? '商品编辑' : '发布商品'" />
```
**shop-edit.uvue**:统一改为"店铺设置"
```html
<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` 是死变量,没有任何地方读它。
```html
<!-- ❌ 旧写法:设置了死变量 -->
<text @click="showAddLevel = true"> 添加等级</text>
<!-- ✅ 修复后:调用方法 -->
<text @click="openAddLevel"> 添加等级</text>
```
**修复**:新增 `openAddLevel()` 方法,重置 `currentLevel` 为空状态后设置 `showEditModal = true`
```typescript
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`),点击"确认",什么都没发生,等级没有被删除。
**根因**
```typescript
// ❌ 旧写法
deleteLevel(id: string) {
uni.showModal({
success: (res: UniShowModalResult): void => {
if (res.confirm === true) {
void this._doDeleteLevel(id) // ← 这里有两个问题
}
}
})
}
```
**两个叠加问题**
1. **`this` 捕获失败**`uni.showModal``success` 回调是一个 `void lambda`UTS 编译为 Kotlin 时会变成跨线程的异步回调Kotlin 协程中的 `suspend` 上下文切换),`this` 引用在回调执行时可能已失效。
2. **`void this._doDeleteLevel(id)` 不可靠**:在非 `suspend``void lambda` 中调用 `async` 方法(编译后是 Kotlin suspend funUTS 不保证 `.then()`/`void` 的执行。实际表现是:回调被丢弃,`_doDeleteLevel` 从未执行。
**修复**:用响应式状态弹窗完全替代 `uni.showModal` 回调模式:
```typescript
// ✅ 修复后:响应式模态框
deleteLevel(id: string) {
this.pendingDeleteId = id
this.showDeleteModal = true
},
async confirmDelete() {
this.showDeleteModal = false
await this._doDeleteLevel(this.pendingDeleteId)
this.pendingDeleteId = ''
},
```
模板中新增删除确认弹窗:
```html
<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会员管理页面无法滑动
**现象**:等级列表和客户列表不能下滑查看。
**根因**
```css
/* ❌ 旧写法:缺少 overflow: hidden */
.list-container {
padding: 20rpx;
flex: 1;
}
```
`flex: 1` 让容器占据剩余高度,但没有 `overflow: hidden`Android 原生渲染器不约束 scroll-view 的高度scroll-view 展开为内容全高,所有内容都"可见"了,但也就不需要滚动,表现为无法滑动。
**修复**
```css
.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 完全相同的根本原因):
```typescript
// ❌ 旧写法
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()` 方法:
```typescript
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.uvue``checkExistingSession()` 还是会把用户送回商家端。
`checkExistingSession()` 的逻辑(简化):
```typescript
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: 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">` 开头补回:
```typescript
<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>` 标签丢失。
实际损坏状态:
```html
<!-- 模板区域内容 -->
</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 内存 session**`await supa.signOut()`
2. **清所有 storage key**token × 3 + user_id + 商家相关 key × 4
3. **清 store 内存态**`setIsLoggedIn(false)` + `setUserProfile({...})`
缺任何一步App 启动时的恢复逻辑都可能把用户重新送回商家端。
### 规律四onShow 中刷新列表必须先重置分页
```typescript
onShow() {
this.page = 1 // 必须
this.hasMore = true // 必须
this.list = [] // 必须
this.loadData()
}
```
### 规律五:多处批量替换后的必检项
```powershell
# 检查三段式结构完整性
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 高度计算
### 根因分析
三个页面的结构本质一致:
```css
/* ❌ 旧写法的共同模式 */
.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 注入明确像素高度**,避免影响小程序现有表现。
#### 购物车 / 我的页面修复模式
```typescript
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`
```html
<view class="page" :style="pageStyle">
<scroll-view
class="content-scroll"
:style="contentStyle"
:scroll-y="true"
></scroll-view>
</view>
```
#### 我的订单页面修复模式
`orders.uvue` 已有 `viewportHeight`,所以只需让这个值真正进入滚动容器:
```typescript
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
```
```typescript
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 渐变回退
### 根因分析
问题不在数据,而在样式实现方式:页面头部和会员条使用了复杂叠层视觉,核心依赖如下写法:
```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`
```css
.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 端切换到稳定回退样式
```html
<view
class="jd-profile-top"
:class="{ 'jd-profile-top-android': isAndroidApp }"
></view>
<view
class="wallet-plus-ribbon"
:class="{ 'wallet-plus-ribbon-android': isAndroidApp }"
></view>
```
```css
.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 |
### 规律二:端差异优先做“条件运行”而不是直接改公共样式
```typescript
// #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_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`JSONBNOT 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` 排除空对象:
```sql
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确认函数版本**
```sql
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查看订单地址数据状态**
```sql
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直接测试函数输出**
```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;
```
结果:`test_address` = **空**,但 `test_gender` = "女"、`test_age` = 78正常
#### 第三轮:逐步拆解 COALESCE
为了确认 `COALESCE` + `NULLIF` 的行为,执行逐步测试:
```sql
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)` 本身的问题,直接测试:
```sql
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` 交互的边界情况。同样的逻辑对 `TEXT`、`INTEGER` 等基础类型工作正常。
---
### 13.5 修复方案
**核心改动**:将 `v_address` 的赋值从 `COALESCE(NULLIF(...))` 改为 `CASE WHEN jsonb_typeof(...) = 'object'`,完全绕过 `COALESCE` + `NULLIF` 的陷阱。
```sql
-- 旧写法有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`
**验证**
```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_