一、起因
去年年底,我决定做一个冰箱食材管理的 App,主要功能是记录食材、推荐菜谱,顺便接入 AI 生成个性化建议。
技术选型的时候,我面临一个选择:是分别写 iOS、Android 和鸿蒙三套代码,还是用跨平台框架?作为一个前端开发者,我自然选择了后者,最终决定用 uni-app x。
选择 uni-app x 的原因很简单:官方说支持鸿蒙,而且可以用类似 TypeScript 的语法写代码。我当时想,这不就是 Web 开发的思路吗?写一套代码,三个平台都能跑,多省事。
结果没想到,光是适配鸿蒙就踩了无数的坑。项目做到现在,我最深的体会是:跨平台开发不是"一次编写,处处运行",而是"一次编写,处处适配"。
这篇文章记录了我在开发过程中遇到的几个典型问题,希望能帮到后来者。
二、UTS 不是 TypeScript
uni-app x 使用 UTS 语言,官方文档说它"类似 TypeScript"。我一开始以为只是换了个名字,代码照写就行了。
结果第一天就被打脸了。
1. Map.forEach 不支持
我在统计功能里用了 Map 来记录食材出现次数,写了这样的代码:
const ingredientMap = new Map<string, number>()
ingredientMap.set('鸡蛋', 3)
ingredientMap.set('番茄', 2)
const stats: IngredientStat[] = []
ingredientMap.forEach((count: number, name: string) => {
stats.push({ name, count })
})
在 iOS 上运行正常,一到鸿蒙就报错:undefined is not callable。
调试了半天才发现,鸿蒙平台的 Map 实现不支持 forEach 方法。这让我很惊讶,因为 forEach 在 JavaScript 里是最基础的 API 之一。
解决办法是改用 for...of 循环:
const stats: IngredientStat[] = []
for (const [name, count] of ingredientMap.entries()) {
stats.push({ name: name, count: count } as IngredientStat)
}
这个改动不难,但问题是我没想到会有这样的限制。这让我意识到,UTS 虽然语法像 TypeScript,但底层编译到 Kotlin/Swift 时,会受到原生平台的约束。
2. 类型隐式转换被禁止
还有一个问题,是条件判断。我习惯这样写:
if (data) {
// 处理数据
}
这在 Web 开发里很常见,但在 UTS 里会报错。因为 UTS 是强类型语言,条件表达式必须是布尔类型,不能用"真值/假值"的概念。
正确的写法是:
if (data != null) {
// 处理数据
}
这种严格的类型检查一开始让我很不适应,但后来发现它确实能避免很多隐藏的 Bug。毕竟 JavaScript 的类型转换规则太复杂了,0、''、null、undefined 在条件判断里的行为都不一样。
3. onLoad 参数的陷阱
页面加载时,我需要从 URL 参数里获取数据。我看官方文档说 onLoad 的参数是 Map 类型,就写了这样的代码:
onLoad((options: Map<string, any | null>) => {
const id = options.get('id')
})
结果又报错了:undefined is not callable。
后来才知道,虽然文档说是 Map 类型,但在鸿蒙平台上,options 实际上是个普通对象。正确的写法是:
onLoad((options: any) => {
if (options !== null && options.id !== null) {
const id = options.id as string
}
})
这个问题让我明白,文档和实际实现之间可能会有差异,不能完全依赖类型标注,要以实际运行结果为准。
三、最难查的 Bug:数据库查询的平台差异
说起来,前面这些坑虽然麻烦,但至少报错信息还算明确。最让我头疼的是一个"看不见的 Bug"。
统计页面在 iOS 上显示正常,数据都对。但一到鸿蒙上,所有统计数字全是 0。界面正常,没有报错,就是数据不对。
问题排查
我先检查了数据库操作,发现查询 SQL 没问题,也能正常执行。然后加了一堆 console.log,发现查询结果确实返回了:
const result = db.query('SELECT COUNT(*) as count FROM saved_recipes')
console.log('查询结果:', JSON.stringify(result.maps))
// iOS: [{"count": 5}]
// 鸿蒙: [{}] // 看起来像空对象?
但是取值的时候就出问题了:
const count = result.maps[0]['count']
console.log('统计数量:', count)
// iOS: 5
// 鸿蒙: undefined
我当时就懵了,明明查询结果里有数据,为什么取不到?
问题根源
查了半天资料,最后在一个 issue 里看到有人提到:鸿蒙平台的查询结果不是普通对象数组,而是 Map<string, any>[]。
这就解释了为什么 JSON.stringify 输出是空对象——Map 对象序列化后本来就是空的。而在 iOS/Android 上,查询结果是普通对象,所以 item['count'] 可以正常取值,但在鸿蒙上必须用 item.get('count')。
这个差异太隐蔽了,因为:
- 没有报错信息
result.maps的长度是正确的- 直接打印对象看不出问题
解决方案
我在基础 Service 类里封装了一个通用方法:
protected static getFieldValue(item: any, field: string, defaultValue: any = null): any {
if (item instanceof Map) {
// 鸿蒙平台:Map 对象
return (item as Map<string, any>).get(field) ?? defaultValue
} else {
// 其他平台:普通对象
return item[field] ?? defaultValue
}
}
然后在所有查询结果处理的地方都改用这个方法:
const result = db.query('SELECT COUNT(*) as count FROM saved_recipes')
const row = result.maps[0]
const count = this.getFieldValue(row, 'count', 0) as number
这样就兼容所有平台了。
深层思考
这个 Bug 让我对跨平台开发有了新的认识。
在 Web 开发里,JavaScript 的数据结构在各个浏览器上基本是一致的。但在跨平台开发里,即使是数组、对象这样的基础数据结构,在不同平台上的实现也可能不同。
鸿蒙之所以返回 Map 对象,可能是因为 UTS 编译到 Kotlin 后,用了 Kotlin 的 Map 类型。而 iOS/Android 可能用的是字典或者其他数据结构,反射到 UTS 层面就是普通对象。
这种差异不是框架的 Bug,而是跨平台开发的本质特征。框架只能保证 API 层面的一致性,底层数据结构的差异还是要开发者自己处理。
四、页面滚动的迷惑行为
还有一个坑,让我调试了整整一个下午。
AI 生成菜谱的结果页面,内容很长,需要滚动查看。我一开始用了 Web 开发的常规思路:
<template>
<view class="page">
<x-navbar title="菜谱详情" />
<scroll-view class="content" scroll-y>
<!-- 菜谱内容 -->
</scroll-view>
</view>
</template>
<style>
.page {
height: 100vh;
}
.content {
flex: 1;
}
</style>
结果页面完全不滚动。
连环踩坑
我开始排查问题:
第一步:去掉 scroll-view,想让页面自然滚动。不行。
第二步:调用 uni.pageScrollTo 滚动到底部。报错:selector invalid。
第三步:检查了半天 CSS,发现鸿蒙不支持 vh 单位。改成 100%,还是不行。
最后我仔细看了一遍页面结构,才发现问题:
<template>
<scroll-view class="content">
<!-- 内容 -->
</scroll-view>
<x-snackbar /> <!-- 提示组件 -->
</template>
我把 Snackbar 组件放在了 scroll-view 外面,导致 <template> 下有两个根节点。这样一来,scroll-view 就不是真正的页面滚动容器了。
正确方案
正确的做法是,scroll-view 必须是 <template> 下的唯一根标签,所有其他组件都要放在里面:
<template>
<scroll-view class="page">
<x-navbar title="菜谱详情" />
<!-- 内容 -->
<x-snackbar /> <!-- 也要放里面 -->
</scroll-view>
</template>
<style>
.page {
width: 100%;
height: 100%; /* 用 100% 而不是 100vh */
padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); /* 安全距离 */
}
</style>
改完之后,滚动立刻正常了,uni.pageScrollTo 也能用了。
为什么这么设计?
我后来想明白了,这是因为 uni-app x 的页面模型和 Web 不同。
在 Web 里,<body> 标签天然就是滚动容器,你写多少内容都会自动滚动。但在原生应用里,页面不会自动滚动,必须明确指定一个滚动容器。
鸿蒙的 CSS 支持也比 Web 少得多,没有 vh/vw 单位,没有 position: sticky,很多 Web 开发的技巧都用不了。这逼着我用原生应用的思路来写布局。
五、收获
踩了这么多坑,我最大的感受是:跨平台开发的难点不在于语法,而在于平台差异。
uni-app x 已经做得很好了,它把三个平台的 API 统一了,让我可以用一套代码来写。但是,底层数据结构、CSS 支持、API 实现细节上的差异,还是需要开发者自己去适配。
给后来者的几点建议:
-
仔细阅读 UTS 与 TypeScript 的差异文档。不要想当然地认为它们是一样的。
-
善用
instanceof和类型判断。在处理查询结果、事件参数等可能有平台差异的地方,先判断类型再操作。 -
详细的日志是最好的调试工具。把关键数据都打印出来,包括类型信息,能快速定位问题。
-
页面布局用原生思维。不要用 Web 那套
vh/vw、position: sticky,老老实实用scroll-view和flex布局。 -
记录踩坑过程。我把每个问题都写成了文档,方便以后查阅,也能分享给团队其他人。
最后想说的是,虽然踩了很多坑,但我并不后悔选择 uni-app x。相比分别写三套原生代码,它已经节省了我大量时间。而且踩坑的过程也让我对跨平台开发有了更深的理解。
从 Web 思维到原生思维的转变,是一个痛苦但必要的过程。这可能就是成长的代价吧。
3 个评论
要回复文章请先登录或注册
用户2920722
青衫行者
潇辰风