uni-app鸿蒙原生应用开发实战(下):核心功能实现与技术细节
作者 :雷达鸭技术团队 | 首发平台 :华为开发者社区 系列定位 :时间线·项目启动期 | 功能模块·架构设计 关键词 :HarmonyOS NEXT、uni-app、架构设计、条件编译、适配层模式、APP-HARMONY 阅读时间 :约 20 分钟 系列导航:上篇·架构设计 → 下篇·功能实现 | 微信一键登录功能开发 | 专题:华为登录 · 数据库与云开发 · AI辅助开发实践
开篇:架构画好了,该写代码了
上一篇我们把架构设计讲清楚了------五层架构、适配层模式、条件编译规范、UTS插件封装。这篇进入编码阶段,聊核心功能的具体实现。
鸿蒙适配的编码工作,最硬的骨头是登录------这是用户入口,缺了它应用跑不通。除此之外,数据埋点和多端适配也是绕不开的课题------没有埋点你不知道用户在用啥,没有适配用户用着不舒服。
这篇文章把三个核心模块的实现代码全部摊开,从前端到后端,从条件编译到云函数,一行一行讲清楚。你可以直接拿去改改就能用。
一、登录模块:从条件编译到服务端对接
登录是鸿蒙适配中改动量最大的功能之一。鸿蒙端用uni.login({provider:'huawei'}),授权流程、服务端对接、用户数据结构都有特定规范。通过适配层封装,业务层只需要调一个方法。
1.1 登录页实现(华为登录部分)
实际项目的登录页支持5种登录方式:微信一键登录、华为账号登录、账号密码登录、手机验证码登录、注册。这里只展示鸿蒙端的华为登录部分,其他登录方式的实现逻辑类似。
vue
<!-- pages/auth/login.vue(华为登录部分摘录) -->
<template>
<view class="login-page">
<!-- 页面头部 -->
<view class="login-page__header">
<text class="login-page__title">登录雷达鸭</text>
<text class="login-page__subtitle">发现优质小项目</text>
</view>
<!-- 登录方式选择 -->
<view class="login-page__methods">
<!-- 华为账号一键登录 - 鸿蒙App -->
<!-- #ifdef APP-HARMONY -->
<button
class="login-page__method login-page__method--huawei"
@click="HandleHuaweiLogin"
>
<view class="login-page__method-icon">🔷</view>
<text class="login-page__method-text">华为账号一键登录</text>
</button>
<!-- #endif -->
<!-- 其他登录方式:微信、账号密码、手机验证码... -->
</view>
<!-- 用户协议 - 合规要求:用户必须主动勾选同意 -->
<view class="login-page__agreement">
<view class="login-page__agreement-checkbox-wrapper" @click="ToggleAgreementCheckbox">
<view class="login-page__agreement-checkbox" :class="{ 'login-page__agreement-checkbox--checked': agreementChecked }">
<text v-if="agreementChecked" class="login-page__agreement-checkmark">✓</text>
</view>
</view>
<text class="login-page__agreement-text-new">
我已阅读并同意
<text class="login-page__agreement-link" @click.stop="ShowUserAgreement">《用户协议》</text>
和
<text class="login-page__agreement-link" @click.stop="ShowPrivacyPolicy">《隐私政策》</text>
</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { AuthManager } from '@/utils/auth-manager'
import { getDeviceInfo } from '@/utils/device-manager'
const agreementChecked = ref(false)
const ToggleAgreementCheckbox = (): void => {
agreementChecked.value = !agreementChecked.value
}
// 华为账号一键登录 - 鸿蒙App
// #ifdef APP-HARMONY
const HandleHuaweiLogin = async (): Promise<void> => {
const agreed = await CheckAgreementAndConfirm()
if (!agreed) return
try {
uni.showLoading({ title: '登录中...', mask: true })
uni.login({
provider: 'huawei',
success: async (loginRes: { errMsg?: string; code?: string }) => {
const { code } = loginRes
if (!code) {
uni.hideLoading()
uni.showToast({ title: '获取华为授权失败', icon: 'none' })
return
}
try {
const deviceInfo = getDeviceInfo()
const result = await uniCloud.callFunction({
name: 'user-service',
data: {
action: 'huawei-login',
data: {
code,
device_id: deviceInfo.deviceId,
device_type: deviceInfo.deviceType
}
}
})
uni.hideLoading()
if (result.result && result.result.code === 0) {
const success = await AuthManager.login(result.result)
if (success) {
uni.showToast({ title: '登录成功', icon: 'success', duration: 2000 })
uni.setStorageSync('login-success-timestamp', Date.now())
setTimeout(() => {
uni.$emit('auth:login-success', result.result.data.userInfo)
uni.switchTab({ url: '/pages/index/index' })
}, 1500)
} else {
uni.showToast({ title: '登录状态保存失败', icon: 'none' })
}
} else {
uni.showToast({
title: result.result?.msg || '登录失败',
icon: 'none',
duration: 3000
})
}
} catch (callFnError) {
uni.hideLoading()
const errDetail = (callFnError as { message?: string })?.message || '请重试'
uni.showToast({ title: '云函数调用失败: ' + errDetail.substring(0, 30), icon: 'none', duration: 3000 })
}
},
fail: (err: unknown) => {
uni.hideLoading()
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
}
})
} catch (error) {
uni.hideLoading()
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
}
}
// #endif
</script>
关键细节:
- 隐私协议检查必须在登录之前 ------
checkAgreement()是第一道关卡,这是华为应用市场审核的硬性要求 uni.login({provider:'huawei'})是HBuilderX 4.31+内置的API,不需要额外集成SDK- 授权码code只使用一次------拿到code后立即发给后端换取token,不能缓存复用
- 登录状态通过
AuthManager.login()统一管理 ------不直接操作uni.setStorageSync,由AuthManager封装token存储、用户信息缓存、登录态同步等逻辑 - 云函数返回格式 ------
result.result.code === 0表示成功,错误信息在result.result.msg字段(不是message) - 错误处理分三层 ------
uni.login的fail处理授权层错误,callFunction的catch处理网络层错误,result.result.code !== 0处理业务层错误
1.2 隐私合规:审核被拒的血泪教训
我们第一次提交华为应用市场审核就被拒了。原因不是代码bug,是隐私政策弹窗的交互方式不符合审核标准。
初版设计是"进入应用即视为同意隐私政策"------用户打开App,弹窗一闪而过,点外部区域就关闭了。审核意见明确要求:用户必须主动勾选复选框并点击同意按钮。
修改后的交互:
- 复选框默认未勾选
- 未勾选时"同意"按钮置灰不可点击
- 勾选后"同意"按钮变为可点击状态
- 点击同意后记录状态,下次启动不再弹
typescript
// 隐私协议检查逻辑(实际代码)
const agreementChecked = ref<boolean>(false)
const isConfirming = ref<boolean>(false)
const ToggleAgreementCheckbox = (): void => {
agreementChecked.value = !agreementChecked.value
}
// 未勾选时弹出二次确认弹框,而不是简单toast
const CheckAgreementAndConfirm = (): Promise<boolean> => {
return new Promise((resolve) => {
if (agreementChecked.value) {
resolve(true)
return
}
if (isConfirming.value) {
resolve(false)
return
}
isConfirming.value = true
uni.showModal({
title: '隐私政策确认',
content: '您需要阅读并同意《用户协议》和《隐私政策》后才能继续登录',
confirmText: '同意',
cancelText: '取消',
success: (res) => {
isConfirming.value = false
if (res.confirm) {
agreementChecked.value = true
resolve(true)
} else {
resolve(false)
}
},
fail: () => {
isConfirming.value = false
resolve(false)
}
})
})
}
这个改动不大,但如果你一开始没按这个标准做,审核被拒后改起来要重新走提审流程,至少耽误2-3天。
1.3 华为登录服务端对接
华为账号登录基于OAuth 2.0授权码模式,服务端需要完成三步:用授权码换取accessToken、从id_token中提取openid、用accessToken获取用户昵称和头像。实际代码比初版方案复杂不少------openid不在Token接口顶层返回,而是嵌在id_token的JWT Payload里,这个坑卡了我大半天。
javascript
// uniCloud/user-service/index.js - huawei-login action
// 引入华为 OAuth 配置(优先公共模块,失败回退本地兜底)
let huaweiConfig
try {
huaweiConfig = require('huawei-config')
} catch (_) {
huaweiConfig = require('./huawei-config')
}
const { HUAWEI_APP_ID, HUAWEI_APP_SECRET } = huaweiConfig
// JWT解码提取openid(华为Token接口不直接返回openid,藏在id_token里)
function decodeJwtPayload(token) {
const parts = token.split('.')
if (parts.length !== 3) return null
const base64url = parts[1]
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const payload = Buffer.from(padded, 'base64').toString('utf8')
return JSON.parse(payload)
}
async function huaweiLogin(data, context) {
const { code, device_id, device_type } = data
if (!code) {
return createResponse(40001, '授权码不能为空')
}
try {
// Step 0: 检查配置是否就绪
if (!HUAWEI_APP_ID || !HUAWEI_APP_SECRET) {
console.error('[huaweiLogin] 配置缺失: HUAWEI_APP_ID=', !!HUAWEI_APP_ID, 'HUAWEI_APP_SECRET=', !!HUAWEI_APP_SECRET)
return createResponse(50002, '服务配置异常,请联系管理员')
}
// Step 1: 用授权码换取accessToken(注意:必须用x-www-form-urlencoded格式)
const tokenUrl = 'https://oauth-login.cloud.huawei.com/oauth2/v3/token'
const tokenRes = await uniCloud.httpclient.request(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: `grant_type=authorization_code&code=${encodeURIComponent(code)}&client_id=${encodeURIComponent(HUAWEI_APP_ID)}&client_secret=${encodeURIComponent(HUAWEI_APP_SECRET)}&redirect_uri=${encodeURIComponent('huawei://oauth/callback')}`,
dataType: 'json',
timeout: 15000
})
const { access_token, id_token, error, error_description } = tokenRes.data || {}
let openid = tokenRes.data && tokenRes.data.openid
// Step 2: 从id_token的JWT Payload中提取openid
if (!openid && id_token) {
const payload = decodeJwtPayload(id_token)
if (payload) {
openid = payload.openid
}
}
if (error) {
console.error('[huaweiLogin] Token接口返回错误:', error, error_description)
return createResponse(40002, error_description || '华为授权失败,请重试')
}
if (!access_token || !openid) {
return createResponse(40002, '获取华为授权失败,未返回有效凭证')
}
// Step 3: 用accessToken获取用户昵称和头像
let nickname = ''
let avatar = ''
try {
const userInfoUrl = 'https://oauth-login.cloud.huawei.com/oauth2/v3/userinfo'
const userInfoRes = await uniCloud.httpclient.request(userInfoUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${access_token}`
},
dataType: 'json',
timeout: 10000
})
const userInfoData = userInfoRes.data
nickname = userInfoData.displayName || ''
avatar = userInfoData.headPictureURL || ''
} catch (userInfoError) {
console.error('[huaweiLogin] 获取华为用户信息失败:', userInfoError.message || userInfoError)
}
// Step 4: 查询或创建用户
const userCollection = db.collection('users')
let user = await userCollection.where({ openid }).get()
if (user.data.length === 0) {
const userData = {
openid,
unionid: '',
nickname: nickname || generateProjectNickname(),
avatar: avatar || 'https://mp-11639816-19e7-45be-9d7d-5ede9af18c2c.cdn.bspapp.com/default-avatar.png',
gender: 0,
country: '中国',
province: '',
city: '',
phone: '',
account: '',
role: 'user',
create_time: Date.now(),
last_login: Date.now(),
status: 'active'
}
const result = await userCollection.add(userData)
user = await userCollection.doc(result.id).get()
} else {
const updateData = { last_login: Date.now() }
if (nickname && !user.data[0].nickname) {
updateData.nickname = nickname
}
if (avatar && !user.data[0].avatar) {
updateData.avatar = avatar
}
await userCollection.doc(user.data[0]._id).update(updateData)
user = await userCollection.doc(user.data[0]._id).get()
}
// Step 5: 设备登录检查(踢下线机制)
const userId = user.data[0]._id
const finalDeviceId = generateDeviceId(context, { device_id })
const finalDeviceType = getDeviceType(context, { device_type })
const deviceCheck = await checkAndRecordDevice(userId, finalDeviceId, finalDeviceType, user.data[0])
if (!deviceCheck.allowed) {
return createResponse(403, deviceCheck.message)
}
// Step 6: 生成安全token(SHA-256哈希,非明文)
const token = generateSecureToken(userId)
const tokenCollection = db.collection('user_tokens')
const now = Date.now()
await tokenCollection.add({
token,
user_id: userId,
device_id: finalDeviceId,
create_time: now,
last_access_time: now,
expire_time: now + TOKEN_EXPIRY
})
return createResponse(0, '登录成功', {
userInfo: user.data[0],
token,
expires_in: Math.floor(TOKEN_EXPIRY / 1000)
})
} catch (error) {
const errMsg = error.message || String(error)
console.error('[huaweiLogin] 登录服务异常:', errMsg)
return createResponse(50001, '登录服务异常: ' + errMsg.substring(0, 200))
}
}
与初版方案的6处关键差异:
- Token请求格式 :华为OAuth v3接口要求
Content-Type: application/x-www-form-urlencoded,不能用JSON body。初版方案用contentType:'json'直接报400错误 - openid获取方式 :Token接口的
openid不在顶层返回,需要从id_token的JWT Payload中解码提取。这是华为OAuth和微信OAuth最大的区别 - 用户信息接口 :正确地址是
oauth-login.cloud.huawei.com/oauth2/v3/userinfo,不是account-api.huawei.com/rest.php?n=userinfo(那是旧版API) - 凭据管理 :
HUAWEI_APP_ID和HUAWEI_APP_SECRET通过huawei-config公共模块管理,不硬编码在云函数里。process.env在uniCloud阿里云部署环境中不可靠,公共模块随代码一同部署更稳定 - Token生成方式 :使用
generateSecureToken()生成SHA-256哈希token,存入user_tokens集合,配合滑动过期机制(30分钟无操作自动过期),不是uniCloud.token()那种7天固定过期 - 设备踢下线 :
checkAndRecordDevice()实现了设备数量限制------同一账号最多N台设备同时在线,超出自动踢掉最早登录的设备
华为账号登录的完整配置步骤和常见问题,参见专题文章:华为账号登录集成方案
二、数据埋点:从0搭建轻量用户行为分析系统
2.1 为什么自建而不是用第三方SDK?
第三方埋点SDK(友盟、神策等)在鸿蒙端的适配进度参差不齐,而且雷达鸭的埋点需求比较轻量------不需要实时大屏、不需要漏斗分析、不需要用户分群。我们只需要知道:用户从哪来、看了什么、点了什么。
自建一套轻量埋点系统,用uniCloud云函数做存储和查询,成本几乎为零。
2.2 埋点架构
设计原则:
- 批量上报------不是每次操作都发请求,攒够50条或30秒定时或应用切后台时批量上报
- 失败重试------上报失败的事件放回队列,下次继续尝试,缓存上限50条防止内存溢出
- 设备信息自动采集------初始化时采集平台、品牌、型号、分辨率、系统版本等,每次上报自动附带
- 隐私合规 ------不采集IMEI、MAC地址等敏感信息,匿名用户使用自生成的
tracker_user_id - 懒初始化------通过Proxy代理实现懒初始化,避免模块加载时干扰小程序生命周期
2.3 Tracker核心实现
实际项目中的Tracker比初版设计复杂不少------从静态类改成了单例模式,增加了曝光埋点、页面停留时长、会话管理、云对象上报+数据库降级等功能。
typescript
// utils/tracker.ts - 核心结构(简化展示,完整代码约660行)
interface TrackData {
eventId: string
eventType: 'click' | 'exposure' | 'page_view' | 'page_exit' | 'custom'
pagePath: string
timestamp: number
userId?: string
sessionId: string
deviceInfo: DeviceInfo
extraData?: Record<string, any>
}
class Tracker {
private config: TrackerConfig
private cache: TrackData[] = []
private sessionId: string = ''
private userId: string = ''
private deviceInfo: DeviceInfo | null = null
private pageTrackMap: Map<string, PageTrackRecord> = new Map()
private reportTimer: number | null = null
private isReporting: boolean = false
constructor(config: Partial<TrackerConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }
this.init()
}
private init() {
this.sessionId = this.generateSessionId()
this.initDeviceInfo()
this.initUserId()
this.startAutoReport()
this.listenAppLifecycle()
}
// 点击埋点
trackClick(elementId: string, extraData?: Record<string, any>) {
this.track({
eventId: elementId,
eventType: 'click',
extraData: { ...extraData, _trackType: 'click', _elementId: elementId }
})
}
// 曝光埋点
trackExposure(elementId: string, extraData?: Record<string, any>) {
this.track({
eventId: elementId,
eventType: 'exposure',
extraData
})
}
// 开始页面停留追踪
startPageTrack(pageName: string, extraData?: Record<string, any>) {
this.endAllPageTrack()
this.pageTrackMap.set(pageName, { pageName, startTime: Date.now(), extraData })
this.track({ eventId: pageName, eventType: 'page_view', extraData })
}
// 结束页面停留追踪(计算停留时长)
endPageTrack(pageName: string) {
const record = this.pageTrackMap.get(pageName)
if (!record) return
const duration = Date.now() - record.startTime
this.track({
eventId: pageName,
eventType: 'page_exit',
extraData: { ...record.extraData, duration, durationSeconds: Math.round(duration / 1000) }
})
this.pageTrackMap.delete(pageName)
}
// 批量上报
async flush() {
if (this.cache.length === 0 || this.isReporting) return
this.isReporting = true
const dataToReport = [...this.cache]
this.cache = []
try {
await this.report(dataToReport)
} catch (error) {
this.cache = [...dataToReport, ...this.cache].slice(0, this.config.maxCacheSize)
} finally {
this.isReporting = false
}
}
private track(partialData: Partial<TrackData>) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const pagePath = currentPage ? currentPage.route : 'unknown'
const trackData: TrackData = {
eventId: partialData.eventId || '',
eventType: partialData.eventType || 'custom',
pagePath,
timestamp: Date.now(),
userId: this.userId,
sessionId: this.sessionId,
deviceInfo: this.deviceInfo!,
extraData: partialData.extraData
}
this.cache.push(trackData)
if (this.cache.length >= this.config.maxCacheSize) {
this.flush()
}
}
// 上报:优先云对象,降级直接写数据库
private async report(data: TrackData[]) {
try {
const trackerService = uniCloud.importObject('tracker-service')
await trackerService.reportEvents({ events: data })
} catch (error) {
try {
const db = uniCloud.database()
await db.collection('tracker_logs').add(data.map(item => ({
...item,
create_time: Date.now()
})))
} catch (dbError) {
// 静默处理,避免影响业务
}
}
}
// 监听应用生命周期
private listenAppLifecycle() {
uni.onAppHide(() => {
this.endAllPageTrack()
this.flush()
})
}
}
// 懒初始化单例(Proxy代理,避免模块加载时干扰生命周期)
let _tracker: Tracker | null = null
function getTracker(): Tracker {
if (!_tracker) {
// #ifdef APP-HARMONY
_tracker = new Tracker({ debug: false })
// #endif
}
return _tracker
}
const tracker = new Proxy({} as Tracker, {
get(_target, prop: string) {
const instance = getTracker()
const value = (instance as Record<string, unknown>)[prop]
if (typeof value === 'function') {
return (value as Function).bind(instance)
}
return value
}
})
export default tracker
与初版设计的3处关键差异:
- 静态类→单例+Proxy :初版用
static方法,模块加载时就初始化。实际发现这会干扰小程序生命周期------改为Proxy懒初始化,首次访问时才创建实例 - 云函数→云对象+降级 :初版用
callFunction调用云函数,实际改为importObject调用云对象(更优雅的调用方式),同时增加数据库直接写入的降级方案 - 仅点击→点击+曝光+停留 :初版只有
track/pageView/click三个方法,实际增加了曝光埋点trackExposure、页面停留时长startPageTrack/endPageTrack、快捷方法trackButtonClick/trackNavClick/trackListItemClick等
2.4 页面埋点接入
typescript
// 在App.vue中全局注册
import tracker from '@/utils/tracker'
export default {
onLaunch() {
tracker.trackClick('app_launch')
},
onHide() {
tracker.flush()
}
}
typescript
// 在具体页面中使用
import { onShow, onHide } from '@dcloudio/uni-app'
import tracker from '@/utils/tracker'
onShow(() => {
tracker.startPageTrack('detail', { opc_id: opcData.value._id })
})
onHide(() => {
tracker.endPageTrack('detail')
})
2.5 云对象存储
javascript
// uniCloud/tracker-service/index.obj.js
const db = uniCloud.database()
module.exports = {
async reportEvents(params = {}) {
const { events = [] } = params
if (!Array.isArray(events) || events.length === 0) {
return { errCode: 40001, errMsg: 'events 不能为空' }
}
try {
const docs = events.map(event => ({
...event,
create_time: Date.now(),
report_time: Date.now()
}))
// 分批插入(每批100条)
const batchSize = 100
const results = []
for (let i = 0; i < docs.length; i += batchSize) {
const batch = docs.slice(i, i + batchSize)
const result = await db.collection('tracker_logs').add(batch)
results.push(result)
}
return {
errCode: 0,
errMsg: 'success',
data: {
inserted: docs.length,
batchCount: results.length
}
}
} catch (error) {
return { errCode: 50001, errMsg: `上报失败: ${error.message}` }
}
},
// 还有 getStats、getUserPath、getPageDurationStats 等统计方法
// 完整代码约500行,这里只展示核心上报方法
}
注意返回格式 :云对象用errCode/errMsg,云函数用code/msg。tracker-service同时支持两种调用方式------通过importObject调用走云对象路径,通过callFunction调用走云函数兼容入口。
三、多端适配:让应用在鸿蒙端"看得好"
3.1 安全区域适配
鸿蒙端的安全区域计算方式和Android/iOS不同,需要单独处理。实际项目中我们封装了system-info.ts工具模块,统一处理系统信息获取和平台标准化:
typescript
// utils/system-info.ts - 核心导出
export interface SystemInfoCompat {
platform: string
brand: string
model: string
system: string
pixelRatio: number
screenWidth: number
screenHeight: number
windowWidth: number
windowHeight: number
statusBarHeight: number
safeArea: SafeArea
safeAreaInsets?: {
bottom: number
top: number
left: number
right: number
}
}
export function getSystemInfoSync(): SystemInfoCompat {
const info = getSystemInfoSyncCompat()
const rawPlatform = info.platform || ''
// 鸿蒙适配:统一标准化平台标识
if (rawPlatform === 'ohos' || rawPlatform === 'harmonyos' || rawPlatform === 'OpenHarmony' || rawPlatform === 'harmony') {
info.platform = 'harmonyos'
}
return info
}
export function getPlatform(): string { /* ... */ }
export function isHarmonyOS(): boolean { return getPlatform() === 'harmonyos' }
为什么需要平台标准化? 鸿蒙版微信中platform可能返回ohos、harmonyos、OpenHarmony等不同值,如果不统一处理,下游的条件编译和平台判断会出问题。
在页面中使用安全区域适配:
typescript
import { getSystemInfoSync, isHarmonyOS } from '@/utils/system-info'
const sysInfo = getSystemInfoSync()
const statusBarHeight = sysInfo.statusBarHeight || 0
const safeAreaTop = sysInfo.safeArea?.top || statusBarHeight
const safeAreaBottom = sysInfo.screenHeight - (sysInfo.safeArea?.bottom || sysInfo.screenHeight)
3.2 导航栏适配
鸿蒙端的导航栏高度与其他平台不同,使用自定义导航栏时需要动态计算:
typescript
// utils/system-info.ts 中获取导航栏高度
import { computed } from 'vue'
import { getSystemInfoSync } from '@/utils/system-info'
export function useNavBarHeight() {
const sysInfo = getSystemInfoSync()
return computed(() => (sysInfo.statusBarHeight || 0) + 44)
}
3.3 图片加载优化
鸿蒙端的图片加载策略与其他平台略有差异,主要在于缓存机制和懒加载的实现:
vue
<!-- components/lazy-image.vue -->
<template>
<view class="lazy-image" :style="{ width: width, height: height }">
<view v-if="!IsLoaded" class="lazy-image__placeholder" :style="{ backgroundColor: placeholderColor }">
<image v-if="placeholder" class="lazy-image__placeholder-img" :src="placeholder" mode="aspectFill" />
<view v-else class="lazy-image__skeleton"></view>
</view>
<image
v-if="ShouldLoad"
class="lazy-image__image"
:class="{ 'lazy-image__image--loaded': IsLoaded }"
:src="src"
:mode="mode"
@load="onImageLoad"
@error="onImageError"
/>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
interface IProps {
src: string
placeholder?: string
placeholderColor?: string
mode?: string
width?: string
height?: string
threshold?: number
}
const props = withDefaults(defineProps<IProps>(), {
placeholder: '',
placeholderColor: '#F5F2ED',
mode: 'aspectFill',
width: '100%',
height: '100%',
threshold: 100
})
const emit = defineEmits<{
load: []
error: []
}>()
const ShouldLoad = ref(false)
const IsLoaded = ref(false)
let observer: UniApp.IntersectionObserver | null = null
onMounted(() => {
observer = uni.createIntersectionObserver({
thresholds: [0]
})
observer.relativeToViewport({ bottom: props.threshold }).observe('.lazy-image', (res) => {
if (res.intersectionRatio > 0) {
ShouldLoad.value = true
observer?.disconnect()
}
})
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
const onImageLoad = () => {
IsLoaded.value = true
emit('load')
}
const onImageError = () => {
emit('error')
}
</script>
与初版设计的差异 :初版用isVisible控制src切换(占位图→真实图),实际改为v-if="ShouldLoad"延迟加载+v-if="!IsLoaded"占位层------这样占位图和真实图是两个独立元素,不会因为src切换导致闪烁。同时增加了骨架屏动画(shimmer效果),在没有设置占位图时显示渐变动画。
3.4 性能优化清单
| 优化项 | 实现方式 | 效果 |
|---|---|---|
| 图片懒加载 | IntersectionObserver + 占位图 | 首屏加载速度提升40% |
| 列表分页 | 触底加载 + 骨架屏 | 内存占用降低60% |
| 组件按需渲染 | v-if + 条件编译 | 非当前平台组件不渲染 |
| 云函数缓存 | 客户端5分钟缓存策略 | 重复请求减少70% |
| 静态资源CDN | 云存储 + OSS加速 | 图片加载速度提升50% |
四、测试与审核:华为应用市场通关指南
4.1 核心测试用例
| 模块 | 测试项 | 预期结果 | 优先级 |
|---|---|---|---|
| 隐私合规 | 首次启动弹出隐私政策弹窗 | 弹窗不可绕过,复选框默认未勾选 | P0 |
| 隐私合规 | 未勾选时点击同意 | 按钮置灰,无响应 | P0 |
| 华为登录 | 点击华为账号登录 | 弹出华为授权页 | P0 |
| 华为登录 | 授权成功 | 跳转首页,用户信息正常展示 | P0 |
| 信息浏览 | 首页瀑布流加载 | 数据正常展示,图片正常加载 | P0 |
4.2 审核常见被拒原因
| 被拒原因 | 解决方案 |
|---|---|
| 隐私政策弹窗不符合要求 | 改为用户主动勾选复选框+明确同意 |
| 用户协议链接失效 | 上架前逐一检查所有外链 |
| 缺少华为账号登录 | 如果支持第三方登录,必须提供华为登录 |
| 应用内更新未走应用市场 | 移除应用内更新功能,引导用户去应用市场更新 |
| 截图与实际功能不符 | 确保应用市场截图反映真实功能 |
4.3 条件编译测试策略
每次改条件编译,两个分支都要测。我们用了一个笨但有效的办法:维护一个测试矩阵。
| 测试场景 | 鸿蒙端 |
|---|---|
| 登录流程 | 华为账号登录 |
| 隐私弹窗 | ✅ |
| 安全区域 | 需要适配 |
| 分享功能 | 系统分享 |
五、踩坑记录:那些文档里没写的东西
坑1:条件编译标签混淆
初期把APP-PLUS当成了"所有App平台",结果鸿蒙端的代码根本没执行。排查了半天才发现APP-PLUS只包含Android/iOS,鸿蒙要用APP-HARMONY。这个坑太隐蔽了------编译不报错,运行不报错,就是功能不生效。
坑2:模板中条件编译破坏DOM结构
某次在模板中条件编译没包含完整DOM结构,鸿蒙端直接白屏。排查了两个小时才发现是模板编译后DOM不闭合导致的。规则:每个条件编译块必须包含完整的DOM结构。
坑3:华为登录授权码只能用一次
授权码code用完即失效,不能缓存复用。如果用户退出登录后重新登录,必须重新调用uni.login获取新的code。
写在最后
编码阶段最深的感触:架构设计决定了代码的上限,但细节处理决定了产品的下限。隐私合规、条件编译------这些细节文档里可能一笔带过,但忽略任何一个都会导致审核被拒或线上故障。
雷达鸭鸿蒙端从开发到上架,核心编码用了6个人天,测试和审核用了3个人天。如果一开始就把隐私合规和条件编译规范定好,至少能省2个人天。
下一篇开始进入专题文章,把登录、数据库、鸿蒙卡片等模块拆开深入讲。每个专题都是可以独立参考的实战指南。
下一篇专题 :华为账号登录集成方案
参考资料: