2981 lines
112 KiB
Markdown
2981 lines
112 KiB
Markdown
# 安卓端调试成功全过程问题总结与修复指南
|
||
|
||
> **文档类型**:经验沉淀文档(非简单周报)
|
||
> **维护人**:黄镇堡
|
||
> **最后更新**:2026-04-20
|
||
> **项目**:智慧医养 mall(uni-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 x(HBuilderX 5.07) |
|
||
| 前端语言 | UTS(TypeScript 子集,编译为 Kotlin) |
|
||
| 后端 | Supabase(PostgreSQL + Edge Functions) |
|
||
| Android 构建 | Android Studio Ladybug + `assembleDebug` |
|
||
| Android 最低版本 | API 21(Android 5.0) |
|
||
|
||
---
|
||
|
||
## 2. 时间范围与排查范围
|
||
|
||
### 2.1 时间范围
|
||
|
||
- **开始**:2026-04-13(开始尝试 Android 本地打包)
|
||
- **完成**:2026-04-20(merchant 端能够在 Android 上正常运行)
|
||
|
||
### 2.2 关键提交节点
|
||
|
||
| 提交 Hash | 提交信息 | 意义 |
|
||
| ----------------------- | ------------------------------ | ----------------------------------------------- |
|
||
| `5ab4789d` | 保存能够打包成功的版本 | **基线**:首次编译通过,但 Android 运行仍有问题 |
|
||
| `8765e948` | 小程序条件编译 | 分离小程序/Android 条件编译 |
|
||
| `5473f0fc` / `3c256812` | 修复页面展示bug | 布局适配修复 |
|
||
| `f99bd5a7` | merchant能够在安卓上面进行运行 | **终点**:Android 端完整可用 |
|
||
|
||
### 2.3 排查范围
|
||
|
||
- **页面**:所有 `pages/mall/merchant/**`(共约 30 个页面)+ `pages/user/login`
|
||
- **公共组件**:`components/merchant-tabbar/`、`utils/eventHelpers.uts`(新增)
|
||
- **配置文件**:`pages.json`、`manifest.json`、`fix_kt_files.ps1`
|
||
- **Android 项目**:`D:\Android\Project\mall\app\src\main\java\`
|
||
|
||
### 2.4 主要错误文件索引
|
||
|
||
| 文件 | 内容 |
|
||
| -------------------------------------- | --------------------------------------------------- |
|
||
| `errors_round1_clean.txt` | A阶段第一轮 UTS 编译错误(清洗版) |
|
||
| `errors_round2.txt` | A阶段第二轮 UTS 编译错误 |
|
||
| `D:\Android\Project\mall\报错信息.txt` | B阶段 Android Studio Kotlin 符号缺失错误 |
|
||
| `kotlinc_errors_real.txt` | 独立 kotlinc 编译尝试的过滤结果(已确认无参考价值) |
|
||
| `build_output_r1_android.txt` | Android Studio assembleDebug 完整输出 |
|
||
|
||
---
|
||
|
||
## 3. 总体结论
|
||
|
||
### 3.1 根本性结论
|
||
|
||
**uni-app x 的 UTS 并不是 JavaScript,是一门独立的强类型语言,编译到 Kotlin 后遵循 Kotlin 的严格类型规则。**
|
||
|
||
大量问题来源于以 JS 习惯写 UTS:
|
||
|
||
- JS 中合法的表达式(`if (str)`、`a === b`、`obj.field`)在 UTS/Kotlin 中要么报编译错误,要么产生意料之外的运行时行为。
|
||
- CSS 在 Web 浏览器中自动降级处理,在 Android 原生渲染器中不行,无效属性直接被忽略或崩溃。
|
||
|
||
### 3.2 三阶段问题分布
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────────────┐
|
||
│ 阶段A:HBuilderX "生成本地打包APP资源" │
|
||
│ 工具:HBuilderX 5.07 UTS编译器 │
|
||
│ 产物:.kt 文件到 unpackage/dist/build/app-android/ │
|
||
│ 问题:UTS 语法错误(13类),编译直接失败 │
|
||
├────────────────────────────────────────────────────────────────────────┤
|
||
│ 阶段B:Android Studio assembleDebug │
|
||
│ 工具:Gradle + Kotlin 编译器 │
|
||
│ 产物:app-debug.apk │
|
||
│ 问题:Kotlin 符号 unresolved(uni SDK AAR缺失)、=== 运行时语义错误 │
|
||
├────────────────────────────────────────────────────────────────────────┤
|
||
│ 阶段C:APK 安装到真机/模拟器运行 │
|
||
│ 工具:Android 真机 │
|
||
│ 问题:布局截断、顶部遮挡、双层TabBar、跳转白屏、无法滚动 │
|
||
└────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.3 问题数量统计
|
||
|
||
| 阶段 | 问题分类数 | 已解决 | 备注 |
|
||
| ---------------- | ---------- | -------- | -------------------------------------------------------- |
|
||
| A(UTS编译) | 13类 | 13类 | |
|
||
| B(Android构建) | 11类 | 9类 | AAR依赖需要补充;getPushClientId待重写 |
|
||
| C(运行适配) | 14类 | 14类 | 含flex-direction、scroll-into-view ID、safe-area双重兜底 |
|
||
| 隐藏问题 | 6类 | 6类 | 含AkReq链路模式、sessionUser访问模式 |
|
||
| **合计** | **44类** | **42类** | |
|
||
|
||
---
|
||
|
||
## 4. 生成本地打包APP资源阶段——UTS 语法问题
|
||
|
||
> **触发场景**:在 HBuilderX 中点击"发行 → 本地打包 → 生成本地打包App资源",UTS 编译器报错,`.kt` 文件无法生成。
|
||
>
|
||
> **错误来源文件**:`errors_round1_clean.txt`、`errors_round2.txt`
|
||
|
||
---
|
||
|
||
### 4.1 非严格布尔条件(最常见)
|
||
|
||
**错误信息**
|
||
|
||
```
|
||
condition type mismatch: String but Boolean expected
|
||
condition type mismatch: Int? but Boolean expected
|
||
```
|
||
|
||
**根因**
|
||
JavaScript 有 truthy/falsy 机制(空字符串、0、null 均为 falsy),UTS/Kotlin 的 `if` 条件**必须是 Boolean 类型**,不接受其他类型隐式转换。
|
||
|
||
**错误写法 → 修复写法**
|
||
|
||
```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 问题6:Android Studio Kotlin 符号 unresolved(uni SDK AAR 缺失)
|
||
|
||
**现象**(来自 `报错信息.txt`):
|
||
|
||
```
|
||
error: unresolved reference: uni_showModal
|
||
error: unresolved reference: uni_showLoading
|
||
error: unresolved reference: ShowModalOptions
|
||
error: unresolved reference: UniShowModalResult
|
||
error: unresolved reference: uni_chooseImage
|
||
error: unresolved reference: ChooseImageOptions
|
||
error: unresolved reference: uni_setClipboardData
|
||
```
|
||
|
||
**根因**:`D:\Android\Project\mall\app\libs\` 中缺少 uni-app SDK 的 AAR 文件,这些 AAR 包含 `uni_showModal` 等原生弹窗 API 的 Kotlin 绑定。
|
||
|
||
**修复方向**:从 HBuilderX 的本地打包资源包中提取标准 AAR 文件,添加到 `app/libs/` 并在 `build.gradle` 中声明:
|
||
|
||
```gradle
|
||
dependencies {
|
||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||
}
|
||
```
|
||
|
||
> ⚠️ **注意**:此问题目前尚未完全解决(临时绕过方案:在涉及这些 API 的页面上暂时注释掉相关调用)。这是当前 Android 调试的最后一个未闭合的问题。
|
||
|
||
---
|
||
|
||
### 5.8 问题7:BOM(Byte Order Mark)字符污染 Kotlin 文件
|
||
|
||
**现象**:某些 `.kt` 文件第一行出现 `锘?` 乱码前缀,Kotlin 编译器报错:
|
||
|
||
```
|
||
error: expecting a top level declaration
|
||
```
|
||
|
||
**根因**:Windows 环境下,某些编辑器或脚本保存文件时使用 UTF-8 with BOM,BOM 字节(`\xEF\xBB\xBF`)被直接写入文件头,导致 Kotlin 编译器无法识别文件开头。
|
||
|
||
**修复**:
|
||
在 `fix_kt_files.ps1` 或单独脚本中,用 `[System.IO.File]::ReadAllText` + `[System.IO.File]::WriteAllText` 重写文件(.NET 默认用无 BOM UTF-8)。或在文件保存时改用 UTF-8 无 BOM 编码。
|
||
|
||
---
|
||
|
||
### 5.9 问题8:`fix_kt_files.ps1` 必须在每次 HBuilderX 生成后运行
|
||
|
||
**现象**:修复过的 `.kt` 文件被重新生成,之前的修复失效,Android Studio 再次报错。
|
||
|
||
**根因**:每次在 HBuilderX 点"生成本地打包App资源",都会覆盖 `unpackage/` 目录下的所有 `.kt` 文件,修复脚本的结果被清空。
|
||
|
||
**当前流程(必须遵守)**:
|
||
|
||
```
|
||
1. HBuilderX → 发行 → 本地打包 → 生成本地打包App资源
|
||
2. 等待编译完成
|
||
3. 手动将 .kt 文件从 unpackage/ 复制到 Android Studio 项目
|
||
4. 运行:powershell -File D:\Android\Project\mall\fix_kt_files.ps1
|
||
5. Android Studio → Build → assembleDebug
|
||
```
|
||
|
||
**规范建议**:将步骤 3+4 整合为一个脚本自动执行。
|
||
|
||
---
|
||
|
||
### 5.10 fix_kt_files.ps1 完整修复内容
|
||
|
||
```powershell
|
||
# 修复内容一览:
|
||
# 1. 全局替换 ' === ' → ' == ' (引用比较 → 值比较)
|
||
# 2. 全局替换 ' !== ' → ' != ' (引用不等 → 值不等)
|
||
# 3. 替换跳转路径 /pages/mall/admin/homePage/index → /pages/mall/merchant/index
|
||
# 4. 清除 MerchantTabBar import 和注册引用
|
||
```
|
||
|
||
---
|
||
|
||
### 5.11 问题9:login.uvue 深层业务逻辑重构(多问题叠加案例)
|
||
|
||
**背景**:`login.uvue` 是整个 merchant 流程的入口,其中的 `parseRoleData` 函数是 UTS 改造最复杂的单一案例——它在一个函数内叠加了 4 类 UTS 语法问题。
|
||
|
||
**问题叠加清单**
|
||
|
||
| # | JS 写法 | UTS 错误类型 | 修复 |
|
||
| --- | ----------------------------------------------- | ------------------------------------ | ------------------------------------------------- |
|
||
| 1 | 参数类型 `any \| null` | 编译器无法推断数组操作 | 定义 `ParseRoleInput` 联合类型 |
|
||
| 2 | `dataArray.length > 0` 在 `any` 上 | unresolved reference | 先 `Array.isArray()` 再 `as Array<UTSJSONObject>` |
|
||
| 3 | `obj.getString('role')` 结果直接参与 `===` 比较 | `String?` 与字面量引用比较永远 false | `?? ''` 兜底 + 改用 `==` |
|
||
| 4 | `id != null` 空字符串漏判 | `getString` 返回 `""` 而非 null | 改为 `id !== ''` |
|
||
|
||
**修复前后对比**
|
||
|
||
```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 问题10:getPushClientId 回调因多重 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 && (...)` | `&&` 要求左侧是 Boolean,UTSJSONObject 不是 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([...])` | 逐个 await(UTS不完全支持) |
|
||
|
||
---
|
||
|
||
### 6.10 `typeof` 操作符在 UTS 中不可用
|
||
|
||
**背景**:`login.uvue` 中因为使用了 `typeof res === 'string'` 导致整段推送 CID 代码无法编译(见 §5.12)。
|
||
|
||
| 平台 | 运行时类型判断 |
|
||
| ------------------- | --------------------------------------------------------- |
|
||
| JavaScript | `typeof x === 'string'`、`x instanceof Array` |
|
||
| UTS(编译到Kotlin) | `x is String`(Kotlin `is` 运算符)或通过类型系统静态保证 |
|
||
|
||
```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 页面
|
||
**现象**:顶部导航条贴着屏幕顶端,内容被系统状态栏(时间、电量区域)遮住约 20–40px。
|
||
|
||
**根因**:顶部安全区代码用条件编译包裹,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 x(Android 渲染器) | `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,全面屏设备为 20–40px。单纯使用 `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 SDK(uni_showModal 等)
|
||
→ 是:检查 app/libs/ 中的 AAR 文件
|
||
→ 否:检查 .kt 文件中是否有 BOM 字节(锘? 开头)
|
||
3. 检查是否有 === 出现(未被 fix 脚本替换)
|
||
4. 全量搜索错误的符号名,确认来自哪个 .uvue/.uts 源文件
|
||
5. 在源文件中修复,重新走完整流程
|
||
```
|
||
|
||
### 10.3 遇到 Android 运行时逻辑错误(看起来代码是对的但行为不对)
|
||
|
||
```
|
||
1. 优先怀疑 === / !== 比较(§5.4):
|
||
→ 在 fix_kt_files.ps1 运行结果中确认替换了多少处
|
||
2. 检查条件分支是否进入了错误分支
|
||
→ 打印关键值到 console.log 观察
|
||
3. 检查 UTSJSONObject 字段是否返回了 null
|
||
→ 改用 getString() 并加 ?? '' 兜底
|
||
4. 检查跳转路由是否存在于 pages.json
|
||
```
|
||
|
||
### 10.4 遇到 Android 页面布局问题
|
||
|
||
```
|
||
1. 内容截断/无法滚动:
|
||
→ 检查根容器是否使用了 min-height: 100%
|
||
→ 确认 scroll-view 的父元素有明确的 height
|
||
→ 改用 flex: 1 模型
|
||
|
||
2. 顶部内容被遮挡:
|
||
→ 检查页面是否 navigationStyle: custom
|
||
→ 检查顶部 padding 是否在 #ifdef MP-WEIXIN 里
|
||
→ 确认使用了 var(--status-bar-height)
|
||
|
||
3. 双层 TabBar:
|
||
→ 检查页面内是否还有 <merchant-tab-bar> 组件
|
||
→ 检查 pages.json 是否配置了 tabBar
|
||
|
||
4. CSS 样式异常:
|
||
→ 检查是否使用了 box-shadow
|
||
→ 检查 linear-gradient 是否有多个停止点
|
||
→ 参考 uni-app x 官方 CSS 支持列表
|
||
```
|
||
|
||
### 10.5 完整构建流程参考
|
||
|
||
```
|
||
[准备阶段]
|
||
1. 确认当前分支:git branch(应在 huangzhenbao-admin)
|
||
2. 确认 pages.json 是 Android 精简版
|
||
|
||
[HBuilderX 编译阶段]
|
||
3. HBuilderX → 发行 → 本地打包 → 生成本地打包App资源
|
||
4. 等待编译完成(底部显示"成功")
|
||
5. 检查是否有错误提示,参照 §4 和 §10.1 处理
|
||
|
||
[Android Studio 阶段]
|
||
6. 复制 .kt 文件到 D:\Android\Project\mall\app\src\main\java\
|
||
7. 运行:powershell -File D:\Android\Project\mall\fix_kt_files.ps1
|
||
8. Android Studio → Build → Clean Project
|
||
9. Android Studio → Build → assembleDebug
|
||
10. 检查 Build Output,参照 §5 和 §10.2 处理
|
||
|
||
[设备运行阶段]
|
||
11. adb install 或直接 Run
|
||
12. 按照 §10.3 和 §10.4 的清单检查运行时问题
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 关键文件与关键改动索引
|
||
|
||
### 11.1 源代码关键改动(`5ab4789d` → `f99bd5a7`)
|
||
|
||
| 文件 | 主要改动 | 所属问题 |
|
||
| ----------------------------------- | --------------------------------------------------------------------------------- | ----------------- |
|
||
| `pages/user/login.uvue` | 3处跳转改为 `merchant/index`;onLoad参数改用 UTSJSONObject | §5.5, §5.6 |
|
||
| `pages/mall/merchant/index.uvue` | 删除 `<merchant-tab-bar>`;删除 `#ifdef MP-WEIXIN` 包裹;容器 `min-height→flex:1` | §7.1, §7.2, §7.3 |
|
||
| `pages/mall/merchant/growth.uvue` | 同上布局修复;`viewDetail` 改接 string;`safe-bottom` 减小 | §7.1, §7.9, §7.4 |
|
||
| `pages/mall/merchant/profile.uvue` | 删 MerchantTabBar;`merchantId` 判空改为 `!== ''`;`any[]→Array<UTSJSONObject>` | §7.3, §7.11, §4.3 |
|
||
| `pages/mall/merchant/chat.uvue` | `statusBarHeight ?? 20` | §7.8 |
|
||
| `pages/mall/merchant/orders.uvue` | 删 MerchantTabBar;布局 flex 化 | §7.1, §7.3 |
|
||
| `pages/mall/merchant/messages.uvue` | 删 `#ifdef MP-WEIXIN` 包裹;删 MerchantTabBar | §7.2, §7.3 |
|
||
| `pages/mall/merchant/finance.uvue` | 布局 flex 化(`.page { flex-column }`,`.scroll { flex:1 }`) | §7.1 |
|
||
| `pages.json` | 移除约 120 个 Web-only 页面路由 | §7.7 |
|
||
| `utils/eventHelpers.uts`(新增) | `getPickerIdx()`、`getInputVal()` 等工具函数 | §4.7 |
|
||
|
||
### 11.2 Android 项目关键文件
|
||
|
||
| 文件 | 作用 |
|
||
| ------------------------------------------ | ------------------------------------------------------------------------ |
|
||
| `D:\Android\Project\mall\fix_kt_files.ps1` | **每次必须运行**:替换 `===→==`、`!==→!=`、修复路径、清除 MerchantTabBar |
|
||
| `D:\Android\Project\mall\app\libs\` | uni-app SDK AAR 文件(需补充) |
|
||
| `D:\Android\Project\mall\app\build.gradle` | `fileTree(dir: 'libs', include: ['*.aar'])` |
|
||
| `D:\Android\Project\mall\报错信息.txt` | B阶段 Kotlin 符号缺失的原始错误日志 |
|
||
|
||
### 11.3 参考文档
|
||
|
||
| 文件 | 内容 |
|
||
| ------------------------------------------------ | -------------------- |
|
||
| `docs/Android打包自检清单.md` | 打包前的逐项自查清单 |
|
||
| `docs/project_spec/UNI_APP_X_PAGE_FIX_GUIDE.md` | 页面级修复指南 |
|
||
| `docs/fix_guide/android_debug_full_fix_guide.md` | **本文档** |
|
||
|
||
### 11.4 关键提交 diff 查看命令
|
||
|
||
```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 fun),UTS 不保证 `.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-13:settings.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-14:App.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` = null(session 已清除)
|
||
- `uni.getStorageSync('user_id')` = null(storage 已清除)
|
||
- 用户停留在登录页,不再被拉回
|
||
|
||
### 涉及文件
|
||
|
||
- `d:\骅锋\mall\pages\mall\merchant\settings.uvue`(主要修改)
|
||
- `d:\骅锋\mall\App.uvue`(无需改动,确认其逻辑本身正确,只要 storage 清干净就不会触发)
|
||
|
||
### 退出后清理的完整 storage key 清单
|
||
|
||
| Key | 说明 |
|
||
| ----------------------------- | --------------------------------------- |
|
||
| `akreq_access_token` | JWT 访问令牌(AkReq 内存+storage 双写) |
|
||
| `akreq_refresh_token` | 刷新令牌 |
|
||
| `akreq_expires_at` | 令牌过期时间戳 |
|
||
| `user_id` | 用户业务 ID(App.uvue 恢复的判断依据) |
|
||
| `userInfo` | 用户信息缓存 |
|
||
| `merchant_id` | 商家 ID |
|
||
| `merchant_idx_cache` | 商家首页骨架屏缓存 |
|
||
| `merchant_orders_pending_tab` | 订单页 tab 状态 |
|
||
| `uni_push2_cid` | 推送客户端 ID |
|
||
|
||
### 经验总结
|
||
|
||
> **规律**:在 UTS 中,凡是需要在用户交互后执行异步操作(清缓存、signOut、网络请求)的场景,必须使用 `async method` + `await`,**不能依赖 `uni.showModal` / `uni.showActionSheet` 等系统弹窗的 `success` 回调中的 Promise 链**。系统弹窗回调是 `void lambda`,在 Kotlin 非 suspend 上下文中,Promise/.then/.catch 的执行不被保证。
|
||
>
|
||
> **退出登录的完整清理清单**:token(access+refresh+expires)+ 用户信息 + 商家 ID + 首页缓存 + 角色缓存 + store 内存态,缺一不可。
|
||
|
||
---
|
||
|
||
## R2-15:members.uvue 编译失败(缺少 import supa 和 type MemberLevel)
|
||
|
||
### 现象
|
||
|
||
HBuilderX 报编译错误,Android Studio Kotlin 编译报大量 `Unresolved reference: supa`、`Unresolved reference: MemberLevel`、`Unresolved reference: id/name/discount_rate` 等。
|
||
|
||
### 根因
|
||
|
||
在一次多文件修复操作中,向 `members.uvue` 的 `<script>` 区域注入了新代码,但同时不小心把原有的 `import supa` 和 `type MemberLevel` 定义移除了(或从未加入)。
|
||
|
||
Kotlin 编译器对每一个符号引用都要求有对应的定义,`supa` 是全局数据库实例(80+ 处引用),`MemberLevel` 是数据类型(70+ 处引用),全部 unresolved。
|
||
|
||
### 修复方式
|
||
|
||
在 `<script lang="uts">` 开头补回:
|
||
|
||
```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-16:members.uvue 模板结构损坏(`</template>` 缺失)
|
||
|
||
### 现象
|
||
|
||
HBuilderX 编译报错:
|
||
|
||
```
|
||
[uni:app-uvue] Invalid end tag.
|
||
file: members.uvue:492:1
|
||
```
|
||
|
||
第 492 行是 `</script>`,错误表明编译器在读到 `</script>` 时还没有找到对应的 `</template>` 关闭标签。
|
||
|
||
### 根因
|
||
|
||
在添加"删除等级确认弹窗"时,多文件替换操作的 `oldString` 匹配了包含 `</view>\n\t</view>\n</template>` 的区块,但生成的 `newString` 重新排布后,模板的 `</view></template>` 结构被混入了 `<script>` 的 `type UserInfo = {` 定义,导致 `<template>` 提前在错误位置"关闭",而真正的 `</template>` 标签丢失。
|
||
|
||
实际损坏状态:
|
||
|
||
```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`(JSONB,NOT NULL DEFAULT `'{}'::jsonb`) |
|
||
|
||
Consumer 端创建订单时,根据 `useAddressSnapshot` 参数决定写入 `address_snapshot` 还是 `address_snapshot_json`。`ec_care_tasks` 表两列同时存在,`hss_service_orders` 表只有 `address_snapshot_json` 列。
|
||
|
||
Delivery 端通过 RPC 调用 `rpc_delivery_order_list` / `rpc_delivery_dashboard` / `rpc_delivery_order_detail` 获取订单,这些 RPC 内部统一调用 `delivery_build_order_json` 函数来构建返回的 JSON 结构。
|
||
|
||
---
|
||
|
||
### 13.3 排查过程
|
||
|
||
#### 第一轮:怀疑空对象阻断 fallback
|
||
|
||
最初怀疑 `address_snapshot_json` 列存储了空对象 `'{}'::jsonb`,导致 `COALESCE` 将其视为有效值而跳过 fallback 到 `address_snapshot`。
|
||
|
||
尝试修复:执行 `20260528_fix_address_fallback.sql`,在 `v_address` 的 `COALESCE` 中加入 `NULLIF` 排除空对象:
|
||
|
||
```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_
|