uni-app鸿蒙原生应用开发实战(下):核心功能实现与技术细节

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>

关键细节

  1. 隐私协议检查必须在登录之前 ------checkAgreement()是第一道关卡,这是华为应用市场审核的硬性要求
  2. uni.login({provider:'huawei'}) 是HBuilderX 4.31+内置的API,不需要额外集成SDK
  3. 授权码code只使用一次------拿到code后立即发给后端换取token,不能缓存复用
  4. 登录状态通过AuthManager.login()统一管理 ------不直接操作uni.setStorageSync,由AuthManager封装token存储、用户信息缓存、登录态同步等逻辑
  5. 云函数返回格式 ------result.result.code === 0表示成功,错误信息在result.result.msg字段(不是message
  6. 错误处理分三层 ------uni.loginfail处理授权层错误,callFunctioncatch处理网络层错误,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处关键差异

  1. Token请求格式 :华为OAuth v3接口要求Content-Type: application/x-www-form-urlencoded,不能用JSON body。初版方案用contentType:'json'直接报400错误
  2. openid获取方式 :Token接口的openid不在顶层返回,需要从id_token的JWT Payload中解码提取。这是华为OAuth和微信OAuth最大的区别
  3. 用户信息接口 :正确地址是oauth-login.cloud.huawei.com/oauth2/v3/userinfo,不是account-api.huawei.com/rest.php?n=userinfo(那是旧版API)
  4. 凭据管理HUAWEI_APP_IDHUAWEI_APP_SECRET通过huawei-config公共模块管理,不硬编码在云函数里。process.env在uniCloud阿里云部署环境中不可靠,公共模块随代码一同部署更稳定
  5. Token生成方式 :使用generateSecureToken()生成SHA-256哈希token,存入user_tokens集合,配合滑动过期机制(30分钟无操作自动过期),不是uniCloud.token()那种7天固定过期
  6. 设备踢下线checkAndRecordDevice()实现了设备数量限制------同一账号最多N台设备同时在线,超出自动踢掉最早登录的设备

华为账号登录的完整配置步骤和常见问题,参见专题文章:华为账号登录集成方案


二、数据埋点:从0搭建轻量用户行为分析系统

2.1 为什么自建而不是用第三方SDK?

第三方埋点SDK(友盟、神策等)在鸿蒙端的适配进度参差不齐,而且雷达鸭的埋点需求比较轻量------不需要实时大屏、不需要漏斗分析、不需要用户分群。我们只需要知道:用户从哪来、看了什么、点了什么。

自建一套轻量埋点系统,用uniCloud云函数做存储和查询,成本几乎为零。

2.2 埋点架构

flowchart TD A[用户操作] --> B[Tracker.trackClick<br/>trackExposure<br/>startPageTrack] B --> C[本地缓存队列<br/>批量缓冲] C -->|队列满50条或30秒定时或应用切后台| D[Tracker.flush<br/>批量上报] D --> E{上报方式} E -->|优先| F[tracker-service云对象] E -->|降级| G[tracker_logs集合<br/>直接写入] F --> H[数据存储] G --> H

设计原则:

  1. 批量上报------不是每次操作都发请求,攒够50条或30秒定时或应用切后台时批量上报
  2. 失败重试------上报失败的事件放回队列,下次继续尝试,缓存上限50条防止内存溢出
  3. 设备信息自动采集------初始化时采集平台、品牌、型号、分辨率、系统版本等,每次上报自动附带
  4. 隐私合规 ------不采集IMEI、MAC地址等敏感信息,匿名用户使用自生成的tracker_user_id
  5. 懒初始化------通过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处关键差异

  1. 静态类→单例+Proxy :初版用static方法,模块加载时就初始化。实际发现这会干扰小程序生命周期------改为Proxy懒初始化,首次访问时才创建实例
  2. 云函数→云对象+降级 :初版用callFunction调用云函数,实际改为importObject调用云对象(更优雅的调用方式),同时增加数据库直接写入的降级方案
  3. 仅点击→点击+曝光+停留 :初版只有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可能返回ohosharmonyosOpenHarmony等不同值,如果不统一处理,下游的条件编译和平台判断会出问题。

在页面中使用安全区域适配:

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个人天。

下一篇开始进入专题文章,把登录、数据库、鸿蒙卡片等模块拆开深入讲。每个专题都是可以独立参考的实战指南。

下一篇专题华为账号登录集成方案


参考资料

相关推荐
G_dou_1 小时前
Flutter三方库适配OpenHarmony【dice_roller】骰子投掷器项目完整实战
flutter·harmonyos
无心使然1 小时前
Openlayers图层按需分层渲染到不同Canvas画布
前端·vue.js·gis
daols881 小时前
vxe-table 实现 Excel 风格向下复制填充(Ctrl + D 键)
javascript·vue.js·excel·vxe-table·vxe-ui
痕忆丶1 小时前
openharmony北向开发问题之HDC端口8710被svchost占用问题
harmonyos
fxshy1 小时前
Vue 组件中 padding 生效了,但竖线还是贴到底边的问题
javascript·vue.js·ecmascript
AI2中文网2 小时前
App Inventor 2 鸿蒙先行版开发进展:从 Android 到 HarmonyOS 的积木编程迁移实录
android·低代码·华为·harmonyos·app inventor
Aotman_2 小时前
JavaScript数组对象中指定字段转换
java·开发语言·前端·javascript·vue.js·前端框架·es6
nashane2 小时前
HarmonyOS 6学习:DevEco Studio跨平台开发环境深度排障指南
学习·华为·harmonyos