UniApp 如何调用鸿蒙预加载

大家好,我是鸿蒙Jack。本期我就不空讲概念了,直接以我的 BabyOne APP 中的背单词功能为例,聊聊 uniapp 里怎么把鸿蒙预加载真正接起来。

我这次要解决的不是"怎么调用一个 API"这么简单,而是一个完整链路的问题:应用安装后第一次打开要快,后面日常打开也要快,但我又不想把整本词书、所有详情页都硬塞进预加载里。最后我采用的是一套比较务实的方案:

安装预加载负责首开目录,周期性预加载负责后续目录更新,详情数据继续走普通接口或云函数兜底。

正式开始之前,先放两个官方入口,这两个链接建议先开着看:

还有一个前提我先说清楚:本文以下示例以"数据来源为开发者服务器"为例来讲,这个能力是需要申请开通的。如果你走的是云开发,整体思路和业务层代码基本一样,只是 AGC 里把数据来源从开发者服务器换成云函数即可。

我为什么会用预加载

BabyOne 里有一个背单词模块。这个页面本身不复杂,首屏就是词书目录、词数统计、每本词书的几个预览词。问题也很典型:这些内容其实非常适合提前准备好,但如果每次都等页面打开后再请求接口,用户第一次进来还是会看到明显的等待。

这类页面最适合吃预加载红利,因为它有三个特点:

第一,首屏数据是静态展示数据,不是强实时数据。

第二,目录比详情轻,适合塞进预加载缓存。

第三,用户对"首开是不是马上有内容"非常敏感。

所以我没有把"整本词书详情"拿去做预加载,而是只把"词书目录 + 每本词书前三个预览词"做成预加载资源。这样做很重要,因为官方文档里写得很清楚,安装预加载缓存上限是 2MB,周期性预加载缓存上限是 3MB。如果你一上来就把大对象塞进去,后面很容易把预加载做成一个看起来高级、实际很脆的功能。

我的整体方案

我在 BabyOneAPP 里的链路是这样的:
AGC 预加载配置
开发者服务器预加载接口
安装预加载缓存
周期性预加载缓存
UniApp App.onLaunch
primeWordbookPrefetch
转存安装预加载结果到本地
registerPrefetchTask 注册周期任务
词书目录页
loadWordbookCatalog
开发者服务器普通接口兜底
词书详情页
loadWordbookBook
普通接口或云函数

这里有两个很关键的落地点。

第一个点,安装预加载的数据只能消费一次。我不想把这个机会浪费在某个随机页面打开上,所以我在 App.vueonLaunch 里就先主动消费一次,然后把结果转存到本地缓存。这样词书目录页第一次真正打开时,数据还是随手可拿。

第二个点,周期性预加载不是"注册完马上有结果"。官方机制决定了它是系统周期拉取,所以它更适合做"后续打开时的缓存更新",而不是替代实时接口。

先把云侧接口想明白

我这次选的是"开发者服务器"作为预加载数据源。这个模式下,AGC 会按照你在预加载服务里配置的下载地址,去请求你的服务器,然后把整个 HTTP body 缓存在设备本地。

对我来说,最合适的接口不是词书详情接口,而是一个纯目录接口:

  • 返回词书列表
  • 返回总词书数、总词条数
  • 每本书只带前三个预览词
  • 不返回整本书的全文内容

这样体积可控,也最贴近首屏展示需求。

我在服务端写了两个层次的代码,一个是路由,一个是数据裁剪逻辑。

开发者服务器路由

/api/word-books/prefetch/catalog 这个接口就是给 AGC 预加载服务用的。它和普通接口最大的区别是:我直接返回纯 payload,不额外包一层 success,因为预加载缓存本身要尽量干净,拿到就能直接渲染。

js 复制代码
// server/routes/word-books.js
const express = require('express')
const router = express.Router()
const { buildCatalogPayload, buildBookPayload } = require('../services/wordbooks')

router.get('/prefetch/catalog', (req, res) => {
  try {
    res.json(buildCatalogPayload())
  } catch (error) {
    console.error('Get word book prefetch catalog error:', error)
    res.status(500).json({
      code: 'SERVER_ERROR',
      message: '获取词书预加载目录失败'
    })
  }
})

router.get('/catalog', (req, res) => {
  try {
    res.json({
      success: true,
      data: buildCatalogPayload()
    })
  } catch (error) {
    console.error('Get word book catalog error:', error)
    res.status(500).json({
      success: false,
      code: 'SERVER_ERROR',
      message: '获取词书目录失败'
    })
  }
})

router.get('/book/:code', (req, res) => {
  try {
    const payload = buildBookPayload(req.params.code)

    if (!payload) {
      return res.status(404).json({
        success: false,
        code: 'WORD_BOOK_NOT_FOUND',
        message: '词书不存在'
      })
    }

    res.json({
      success: true,
      data: payload
    })
  } catch (error) {
    console.error('Get word book detail error:', error)
    res.status(500).json({
      success: false,
      code: 'SERVER_ERROR',
      message: '获取词书详情失败'
    })
  }
})

module.exports = router

把大数据裁成适合预加载的目录数据

这里我专门做了一层 buildCatalogPayload()。原因很简单,预加载不是后端接口镜像,它应该只返回"值得提前缓存的内容"。

js 复制代码
// server/services/wordbooks.js
const wordBooks = require('../data/word-books.cjs')

function createPreviewWord(word) {
  return {
    id: word.id,
    rank: word.rank,
    word: word.word,
    pos: word.pos,
    translation: word.translation,
    usPhone: word.usPhone || '',
    ukPhone: word.ukPhone || '',
    memoryTip: word.memoryTip || ''
  }
}

function createWordEntry(word) {
  return {
    id: word.id,
    rank: word.rank,
    word: word.word,
    pos: word.pos,
    translation: word.translation,
    definition: word.definition || '',
    usPhone: word.usPhone || '',
    ukPhone: word.ukPhone || '',
    sentence: word.sentence || '',
    sentenceCn: word.sentenceCn || '',
    phrase: word.phrase || '',
    phraseCn: word.phraseCn || '',
    memoryTip: word.memoryTip || '',
    relatedWords: Array.isArray(word.relatedWords) ? word.relatedWords : [],
    tags: Array.isArray(word.tags) ? word.tags : []
  }
}

function createBookSummary(book) {
  return {
    id: book.id,
    code: book.code,
    title: book.title,
    shortTitle: book.shortTitle,
    subtitle: book.subtitle,
    emoji: book.emoji,
    accentColor: book.accentColor,
    description: book.description,
    wordCount: book.wordCount,
    previewWords: Array.isArray(book.words) ? book.words.slice(0, 3).map(createPreviewWord) : []
  }
}

function findBook(code) {
  if (!code) {
    return null
  }

  const normalized = String(code).trim().toLowerCase()
  return wordBooks.find((book) => {
    return book.code.toLowerCase() === normalized || book.id.toLowerCase() === normalized
  }) || null
}

function buildCatalogPayload() {
  const books = wordBooks.map(createBookSummary)
  const totalWords = books.reduce((sum, book) => sum + (book.wordCount || 0), 0)

  return {
    scope: 'catalog',
    updatedAt: new Date().toISOString(),
    totalBooks: books.length,
    totalWords,
    books,
    featuredBookCode: books[0]?.code || ''
  }
}

function buildBookPayload(code) {
  const book = findBook(code)
  if (!book) {
    return null
  }

  return {
    scope: 'book',
    updatedAt: new Date().toISOString(),
    book: {
      id: book.id,
      code: book.code,
      title: book.title,
      shortTitle: book.shortTitle,
      subtitle: book.subtitle,
      emoji: book.emoji,
      accentColor: book.accentColor,
      description: book.description,
      wordCount: book.wordCount,
      words: Array.isArray(book.words) ? book.words.map(createWordEntry) : []
    }
  }
}

module.exports = {
  buildCatalogPayload,
  buildBookPayload,
  findBook
}

最后把路由挂到服务入口上:

js 复制代码
// server/index.js
app.use('/api/word-books', require('./routes/word-books'))

如果你用的是开发者服务器模式,AGC 里安装预加载和周期性预加载的下载地址,都可以指向这个目录接口,比如:

text 复制代码
https://你的域名/api/word-books/prefetch/catalog

官方文档里提到,开发者服务器模式会通过 HTTP GET 把 appIdtokenparams 等 query 参数带过来。我的这个目录接口没有做用户态差异化,所以直接忽略这些 query 参数就够了。如果你做的是个性化首页、个性化推荐,再去解析 tokenparams 更合适。

UniApp 里我没有直接写 ArkTS,而是先封成 UTS 插件

这一步是整个方案的核心。因为 uniapp 业务层不适合到处散落鸿蒙原生调用,我最后把预加载相关逻辑封成了一个通用插件:jack-cloud-prefetch。有需要的朋友,也可以去uniapp的官方插件市场下载:https://ext.dcloud.net.cn/plugin?name=jack-cloud-prefetch

不过这里的边界我也说清楚,免得你看完误会。这个插件支持"开发者服务器模式"的前提是:AGC 预加载服务先去请求你配置好的开发者服务器地址,再把结果缓存到本地,插件负责读这个缓存。也就是说,它支持开发者服务器作为预加载数据源,但它自己当前并不直接发 HTTP 请求去访问开发者服务器。真正的直连开发者服务器兜底,我现在还是放在业务层普通接口里处理。

这个插件主要做了四件事:

一是消费安装预加载。

二是注册周期性预加载任务。

三是读取周期性预加载结果。

四是在预加载不可用时,保留云函数兜底能力。

插件接口定义

ts 复制代码
// uni_modules/jack-cloud-prefetch/utssdk/interface.uts
export interface JackCloudPrefetchConfig {
  functionName ?: string
  functionVersion ?: string
  timeout ?: number
  periodicToken ?: string
  periodicParams ?: string
}

export interface PrimeJackCloudPrefetchOptions extends JackCloudPrefetchConfig {
  registerPeriodic ?: boolean
  forcePeriodicRegister ?: boolean
}

export interface FetchJackCloudPrefetchOptions extends JackCloudPrefetchConfig {
  forceCloud ?: boolean
  preferPeriodicPrefetch ?: boolean
  installOnly ?: boolean
  skipCloudFunctionFallback ?: boolean
  cloudData ?: UTSJSONObject
}

export interface CallJackCloudFunctionOptions extends JackCloudPrefetchConfig {
  data ?: UTSJSONObject
}

export interface JackCloudPrefetchResponse {
  source : string
  data : UTSJSONObject
  timestamp : number
  token : string
}

export interface JackCloudPrefetchState {
  installCacheReady : boolean
  periodicTaskRegistered : boolean
  periodicTaskExpireAt : number
}

export type ConfigureJackCloudPrefetch = (config ?: JackCloudPrefetchConfig | null) => void
export type PrimeJackCloudPrefetch = (options ?: PrimeJackCloudPrefetchOptions | null) => Promise<JackCloudPrefetchState>
export type FetchJackCloudPrefetch = (options ?: FetchJackCloudPrefetchOptions | null) => Promise<JackCloudPrefetchResponse>
export type CallJackCloudFunction = (options ?: CallJackCloudFunctionOptions | null) => Promise<JackCloudPrefetchResponse>
export type GetJackCloudPrefetchState = () => JackCloudPrefetchState
export type ClearJackCloudPrefetchInstallCache = () => void

export function configureJackCloudPrefetch(config ?: JackCloudPrefetchConfig | null): void;
export function primeJackCloudPrefetch(options ?: PrimeJackCloudPrefetchOptions | null): Promise<JackCloudPrefetchState>;
export function fetchJackCloudPrefetch(options ?: FetchJackCloudPrefetchOptions | null): Promise<JackCloudPrefetchResponse>;
export function callJackCloudFunction(options ?: CallJackCloudFunctionOptions | null): Promise<JackCloudPrefetchResponse>;
export function getJackCloudPrefetchState(): JackCloudPrefetchState;
export function clearJackCloudPrefetchInstallCache(): void;

鸿蒙实现

这里是真正调用 cloudResPrefetch 的地方。我把它写成一个很实用的顺序:

先读安装预加载,再注册周期任务,真正取目录时优先读本地转存的安装缓存,然后再读周期缓存,最后才回退到云函数。

ts 复制代码
// uni_modules/jack-cloud-prefetch/utssdk/app-harmony/index.uts
import { cloudFunction, cloudResPrefetch } from '@kit.CloudFoundationKit'
import {
  JackCloudPrefetchConfig,
  PrimeJackCloudPrefetchOptions,
  FetchJackCloudPrefetchOptions,
  CallJackCloudFunctionOptions,
  JackCloudPrefetchResponse,
  JackCloudPrefetchState,
  ConfigureJackCloudPrefetch,
  PrimeJackCloudPrefetch,
  FetchJackCloudPrefetch,
  CallJackCloudFunction,
  GetJackCloudPrefetchState,
  ClearJackCloudPrefetchInstallCache
} from '../interface.uts'

const STORAGE_INSTALL_CACHE_KEY = 'jack_cloud_prefetch_install_cache'
const STORAGE_PERIODIC_EXPIRE_AT_KEY = 'jack_cloud_prefetch_periodic_expire_at'
const PERIODIC_REGISTER_INTERVAL = 48 * 60 * 60 * 1000
const DEFAULT_FUNCTION_NAME = ''
const DEFAULT_FUNCTION_VERSION = '$latest'
const DEFAULT_TIMEOUT = 10 * 1000
const DEFAULT_PERIODIC_PARAMS = ''

let configuredFunctionName = DEFAULT_FUNCTION_NAME
let configuredFunctionVersion = DEFAULT_FUNCTION_VERSION
let configuredTimeout = DEFAULT_TIMEOUT
let configuredPeriodicToken = ''
let configuredPeriodicParams = DEFAULT_PERIODIC_PARAMS

function applyConfig(config ?: JackCloudPrefetchConfig | null) : void {
  if (config == null) {
    return
  }

  if (config.functionName != null && config.functionName.length > 0) {
    configuredFunctionName = config.functionName
  }
  if (config.functionVersion != null && config.functionVersion.length > 0) {
    configuredFunctionVersion = config.functionVersion
  }
  if (config.timeout != null && config.timeout > 0) {
    configuredTimeout = config.timeout
  }
  if (config.periodicToken != null) {
    configuredPeriodicToken = config.periodicToken
  }
  if (config.periodicParams != null && config.periodicParams.length > 0) {
    configuredPeriodicParams = config.periodicParams
  }
}

function getStorageString(key : string) : string {
  const value : string | null = uni.getStorageSync(key) as string | null
  if (value == null || value === '') {
    return ''
  }
  return value
}

function getStorageNumber(key : string) : number {
  const raw = getStorageString(key)
  if (raw.length === 0) {
    return 0
  }
  return Number(raw)
}

function setStorageString(key : string, value : string) : void {
  uni.setStorageSync(key, value)
}

function removeStorageValue(key : string) : void {
  uni.removeStorageSync(key)
}

function readInstallCache() : UTSJSONObject | null {
  const raw = getStorageString(STORAGE_INSTALL_CACHE_KEY)
  if (raw.length === 0) {
    return null
  }
  try {
    return UTS.JSON.parse(raw) as UTSJSONObject
  } catch (error) {
    return null
  }
}

function takeInstallCache() : UTSJSONObject | null {
  const cached = readInstallCache()
  if (cached != null) {
    removeStorageValue(STORAGE_INSTALL_CACHE_KEY)
  }
  return cached
}

function unwrapResponsePayload(payload : UTSJSONObject) : UTSJSONObject {
  const successValue : boolean | null = payload['success'] as boolean | null
  const dataValue : Object | null = payload['data'] as Object | null
  if (successValue === true && dataValue != null) {
    return normalizePayload(dataValue)
  }
  return payload
}

function writeInstallCache(data : UTSJSONObject) : void {
  setStorageString(STORAGE_INSTALL_CACHE_KEY, UTS.JSON.stringify(unwrapResponsePayload(data)))
}

function normalizePayload(payload : Object | null) : UTSJSONObject {
  if (payload == null) {
    return {} as UTSJSONObject
  }
  const firstParsed = UTS.JSON.parse(JSON.stringify(payload)) as Object
  if (typeof firstParsed === 'string') {
    return UTS.JSON.parse(firstParsed as string) as UTSJSONObject
  }
  return firstParsed as UTSJSONObject
}

function createResponse(source : string, data : UTSJSONObject, timestamp : number, token : string = '') : JackCloudPrefetchResponse {
  return {
    source,
    data,
    timestamp,
    token
  }
}

function hasPeriodicRegistration() : boolean {
  return getStorageNumber(STORAGE_PERIODIC_EXPIRE_AT_KEY) > Date.now()
}

function ensurePeriodicRegistration(forceRegister : boolean = false) : void {
  if (!forceRegister && hasPeriodicRegistration()) {
    return
  }

  const options : cloudResPrefetch.PrefetchOptions = {
    token: configuredPeriodicToken,
    params: configuredPeriodicParams
  }
  cloudResPrefetch.registerPrefetchTask(options)

  const expireAt = Date.now() + PERIODIC_REGISTER_INTERVAL
  setStorageString(STORAGE_PERIODIC_EXPIRE_AT_KEY, String(expireAt))
}

function ensureFunctionConfigured() : void {
  if (configuredFunctionName.length === 0) {
    throw new Error('cloud function name is required')
  }
}

async function callConfiguredCloudFunction(data : UTSJSONObject) : Promise<UTSJSONObject> {
  ensureFunctionConfigured()
  const params : cloudFunction.FunctionParams = {
    name: configuredFunctionName,
    version: configuredFunctionVersion,
    timeout: configuredTimeout,
    data
  }
  const result : cloudFunction.FunctionResult = await cloudFunction.call(params)

  const normalized = normalizePayload(result.result)
  const errorCode : number | string | null = normalized['code'] as number | string | null
  if (errorCode != null && Number(errorCode) >= 400) {
    const messageValue : string | null = normalized['message'] as string | null
    throw new Error(messageValue == null ? 'cloud function failed' : String(messageValue))
  }
  return unwrapResponsePayload(normalized)
}

export const configureJackCloudPrefetch : ConfigureJackCloudPrefetch = function (config ?: JackCloudPrefetchConfig | null) : void {
  applyConfig(config)
}

export const primeJackCloudPrefetch : PrimeJackCloudPrefetch = async function (
  options ?: PrimeJackCloudPrefetchOptions | null
) : Promise<JackCloudPrefetchState> {
  applyConfig(options)

  try {
    const installResult = await cloudResPrefetch.getPrefetchResult(cloudResPrefetch.PrefetchMode.INSTALL_PREFETCH)
    writeInstallCache(normalizePayload(installResult.result))
  } catch (error) {
  }

  if (options?.registerPeriodic != false) {
    try {
      ensurePeriodicRegistration(options?.forcePeriodicRegister == true)
    } catch (error) {
    }
  }

  return getJackCloudPrefetchState()
}

export const fetchJackCloudPrefetch : FetchJackCloudPrefetch = async function (
  options ?: FetchJackCloudPrefetchOptions | null
) : Promise<JackCloudPrefetchResponse> {
  applyConfig(options)

  if (options?.forceCloud != true) {
    const installCache = takeInstallCache()
    if (installCache != null) {
      return createResponse('install_prefetch_cache', installCache, Date.now())
    }

    if (options?.preferPeriodicPrefetch != false) {
      try {
        ensurePeriodicRegistration(false)
        const periodicResult = await cloudResPrefetch.getPrefetchResult(cloudResPrefetch.PrefetchMode.PERIODIC_PREFETCH)
        const timestamp = periodicResult.timestamp.getTime()
        return createResponse(
          'periodic_prefetch',
          unwrapResponsePayload(normalizePayload(periodicResult.result)),
          timestamp,
          periodicResult.token ?? ''
        )
      } catch (error) {
      }
    }
  }

  if (options?.installOnly == true || options?.skipCloudFunctionFallback == true) {
    throw new Error('prefetch cache unavailable')
  }

  const data = await callConfiguredCloudFunction(options?.cloudData ?? ({} as UTSJSONObject))
  return createResponse('cloud_function', data, Date.now())
}

export const callJackCloudFunction : CallJackCloudFunction = async function (
  options ?: CallJackCloudFunctionOptions | null
) : Promise<JackCloudPrefetchResponse> {
  applyConfig(options)

  const data = await callConfiguredCloudFunction(options?.data ?? ({} as UTSJSONObject))

  return createResponse('cloud_function', data, Date.now())
}

export const getJackCloudPrefetchState : GetJackCloudPrefetchState = function () : JackCloudPrefetchState {
  return {
    installCacheReady: readInstallCache() != null,
    periodicTaskRegistered: hasPeriodicRegistration(),
    periodicTaskExpireAt: getStorageNumber(STORAGE_PERIODIC_EXPIRE_AT_KEY)
  }
}

export const clearJackCloudPrefetchInstallCache : ClearJackCloudPrefetchInstallCache = function () : void {
  removeStorageValue(STORAGE_INSTALL_CACHE_KEY)
}

这里我专门把 PERIODIC_REGISTER_INTERVAL 设成了 48 小时,不是拍脑袋定的。因为周期性预加载的任务注册间隔本来就要大于 12 小时,我这里干脆设得更保守一点,避免应用频繁重复注册,又不至于太久不续命。

业务层怎么接

插件封好之后,uniapp 业务层其实就很顺了。我的原则是:页面不要直接关心鸿蒙 API,页面只管拿结果。

先做一个统一的业务服务层

js 复制代码
// utils/wordbook-service.js
import { getWordbookBook as getWordbookBookFromApi, getWordbookCatalog as getWordbookCatalogFromApi } from '@/utils/api.js'

// #ifdef APP-HARMONY
import {
  clearJackCloudPrefetchInstallCache,
  configureJackCloudPrefetch,
  fetchJackCloudPrefetch,
  primeJackCloudPrefetch
} from '@/uni_modules/jack-cloud-prefetch'
// #endif

const ENABLE_WORDBOOK_PREFETCH = true

let wordbookPrefetchPrimePromise = null
let wordbookPrefetchConfigured = false

function ensureWordbookPrefetchConfigured() {
  // #ifdef APP-HARMONY
  if (wordbookPrefetchConfigured) {
    return
  }

  configureJackCloudPrefetch({
    functionName: 'jack-wordbook-cloud',
    functionVersion: '$latest',
    timeout: 10000,
    periodicParams: '{"scope":"catalog"}'
  })
  wordbookPrefetchConfigured = true
  // #endif
}

function unwrapWordbookPayload(payload) {
  if (payload && payload.success === true && payload.data) {
    return payload.data
  }
  return payload || {}
}

function createWordbookResponse(source, payload, timestamp = Date.now(), token = '') {
  return {
    source,
    data: unwrapWordbookPayload(payload),
    timestamp,
    token
  }
}

export async function primeWordbookPrefetch() {
  // #ifdef APP-HARMONY
  if (!ENABLE_WORDBOOK_PREFETCH) {
    return null
  }

  ensureWordbookPrefetchConfigured()

  if (!wordbookPrefetchPrimePromise) {
    wordbookPrefetchPrimePromise = primeJackCloudPrefetch({
      registerPeriodic: true
    }).catch((error) => {
      console.warn('初始化词书预加载失败:', error)
      return null
    })
  }

  return wordbookPrefetchPrimePromise
  // #endif

  return null
}

export async function loadWordbookCatalog(options = {}) {
  const forceRefresh = options === true || options?.forceRefresh === true

  // #ifdef APP-HARMONY
  if (ENABLE_WORDBOOK_PREFETCH) {
    ensureWordbookPrefetchConfigured()

    if (forceRefresh) {
      try {
        clearJackCloudPrefetchInstallCache()
      } catch (error) {
        console.warn('清理词书安装预加载缓存失败:', error)
      }
    } else {
      await primeWordbookPrefetch()
      try {
        const result = await fetchJackCloudPrefetch({
          preferPeriodicPrefetch: true,
          skipCloudFunctionFallback: true,
          cloudData: {
            action: 'catalog'
          }
        })
        return createWordbookResponse(
          result?.source || 'prefetch_cache',
          result?.data,
          result?.timestamp,
          result?.token || ''
        )
      } catch (error) {
        console.warn('读取词书预加载失败,改为请求开发者服务器:', error)
      }
    }
  }
  // #endif

  const res = await getWordbookCatalogFromApi()
  return createWordbookResponse('server_api', res?.data, Date.now())
}

export async function loadWordbookBook(code) {
  const res = await getWordbookBookFromApi(code)
  return createWordbookResponse('server_api', res?.data, Date.now())
}

这个封装的好处很直接:页面无论跑在鸿蒙、H5、还是别的平台,拿到的返回结构都一致,都是 { source, data, timestamp, token }。这样预加载只是优化手段,不会把业务层写得很散。

App 启动时先把安装预加载吃掉

这是我觉得最值的一步。如果你把安装预加载的首次消费放到目录页 onLoad 再做,页面第一次打开时还是得等。那不如在 App 启动阶段就先做了。

vue 复制代码
<!-- App.vue -->
<script>
  // #ifdef APP-HARMONY
  import { primeWordbookPrefetch } from '@/utils/wordbook-service.js'
  // #endif

  export default {
    onLaunch: function(options) {
      // #ifdef APP-HARMONY
      // 启动时提前消费一次安装预加载,并转存到本地,供词书目录首次打开使用
      primeWordbookPrefetch()
      // #endif
    }
  }
</script>

页面里只管取目录

页面层就简单很多了。它不需要知道当前命中的是安装预加载、周期性预加载,还是普通接口,只管把目录拿来渲染。

vue 复制代码
<!-- pages/wordbook/index.vue -->
<script>
import { loadWordbookCatalog } from '@/utils/wordbook-service.js'

export default {
  data() {
    return {
      loading: true,
      errorMessage: '',
      books: [],
      totalBooks: 0,
      totalWords: 0
    }
  },
  onLoad() {
    this.loadCatalog()
  },
  onPullDownRefresh() {
    this.loadCatalog(true)
  },
  methods: {
    async loadCatalog(forceRefresh = false) {
      this.loading = true
      this.errorMessage = ''
      try {
        const result = await loadWordbookCatalog({
          forceRefresh
        })
        const payload = result?.data || {}
        this.books = payload.books || []
        this.totalBooks = payload.totalBooks || this.books.length
        this.totalWords = payload.totalWords || 0
      } catch (error) {
        console.error('加载词书目录失败:', error)
        this.errorMessage = error?.message || '加载词书目录失败'
      } finally {
        this.loading = false
        uni.stopPullDownRefresh()
      }
    },
    openBook(book) {
      uni.navigateTo({
        url: `/pages/wordbook/session?code=${encodeURIComponent(book.code)}`
      })
    }
  }
}
</script>

详情页继续走普通接口:

vue 复制代码
<!-- pages/wordbook/session.vue -->
<script>
import { loadWordbookBook } from '@/utils/wordbook-service.js'

export default {
  data() {
    return {
      code: '',
      loading: true,
      errorMessage: '',
      book: {},
      words: [],
      currentIndex: 0
    }
  },
  onLoad(options) {
    this.code = options.code || ''
    this.loadBook()
  },
  methods: {
    async loadBook() {
      if (!this.code) {
        this.errorMessage = '缺少词书编码'
        this.loading = false
        return
      }

      this.loading = true
      this.errorMessage = ''

      try {
        const result = await loadWordbookBook(this.code)
        const payload = result?.data || {}
        this.book = payload.book || {}
        this.words = this.book.words || []
        if (this.book.title) {
          uni.setNavigationBarTitle({
            title: this.book.title
          })
        }
        if (this.currentIndex >= this.words.length) {
          this.currentIndex = 0
        }
      } catch (error) {
        console.error('加载词书失败:', error)
        this.errorMessage = error?.message || '加载词书失败'
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

普通接口封装

为了让非鸿蒙平台也能无缝跑,我保留了普通 API:

js 复制代码
// utils/api.js
export function getWordbookCatalog() {
  return request({
    url: '/word-books/catalog',
    method: 'GET',
    auth: false
  })
}

export function getWordbookBook(code) {
  return request({
    url: `/word-books/book/${encodeURIComponent(code)}`,
    method: 'GET',
    auth: false
  })
}

这套链路真实跑起来是什么时序

开发者服务器 词书目录页 本地缓存 cloudResPrefetch jack-cloud-prefetch App.onLaunch 用户 开发者服务器 词书目录页 本地缓存 cloudResPrefetch jack-cloud-prefetch App.onLaunch 用户 alt [首装缓存存在] [首装缓存不存在] alt [本地安装缓存存在] [周期缓存存在] [预加载未命中] primeWordbookPrefetch() getPrefetchResult(INSTALL_PREFETCH) 安装预加载结果 转存目录缓存 抛错 registerPrefetchTask(token, params) 打开词书目录 fetchJackCloudPrefetch() 读取并返回 install_prefetch_cache getPrefetchResult(PERIODIC_PREFETCH) periodic_prefetch GET /api/word-books/catalog server_api

这个时序图里最重要的一点,就是"安装预加载"和"周期性预加载"不是互斥关系,而是前后接力关系。

我自己的经验是:

安装预加载最适合首开首页。

周期性预加载最适合首页后续更新。

详情页、搜索页、个性化页不要硬塞进预加载。

安装预加载和周期性预加载,我是怎么取舍的

这一段我单独说一下,因为很多人第一次接这个能力,最容易把这两种预加载混在一起。

安装预加载解决的是"装完应用第一次打开"的速度问题。它的特点是够快,但只适合第一次,而且缓存只能被消费一次。所以我的处理方式不是让页面直接去吃它,而是在 App.onLaunch 里先拿出来,再自己转存。

周期性预加载解决的是"后面几次打开也别慢"的问题。它不是实时接口,它更像一个由系统帮你维护的离线缓存任务。你需要先注册任务,系统再按它的节奏拉取数据。也就是说,周期性预加载天生就适合目录、活动页、离线资源包这些"允许略有延迟,但希望更快展示"的内容。

这也是为什么我在插件里保留了普通接口兜底。预加载是加速器,不是单点依赖。

鸿蒙侧前提我也补一下

除了 AGC 开通预加载服务之外,工程里至少要有网络权限:

json 复制代码
// harmony-configs/entry/src/main/module.json5
"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET"
  }
]

这一点很基础,但我还是建议你文章里带一下,不然有些人照着接完,最后连云函数和开发者服务器都打不通。

如果你用的是云开发,写法也差不多

本文重点示例是开发者服务器,因为这个模式更直观,也更适合把现有后端接口平滑接过来。但如果你走的是云开发,整体思路并不会变:

  • AGC 里把预加载数据来源切到云函数
  • 云侧接口从开发者服务器 GET 接口变成 Cloud Foundation 云函数
  • uniapp 业务层和页面层基本不用改

文章最后,我把这套方案的关键点再收一下

我这次在 BabyOneAPP 里做 uniapp 调用鸿蒙预加载,核心并不是单纯调用 getPrefetchResult,而是把下面这几件事串成了一条稳定链路:

开发者服务器提供可控、轻量的目录预加载数据。

UTS 插件把 cloudResPrefetchcloudFunction.call 收口。

App 启动阶段提前消费安装预加载。

页面层统一走业务服务,不关心底层命中来源。

预加载失效时,普通接口继续兜底。

这样做下来,整个功能的收益很明确:首开目录页更快,后续打开也更稳,而且不会因为预加载失效把页面直接搞挂。

如果你也在做 uniapp 的鸿蒙项目,我的建议是先别想着"全站预加载",先挑一个最典型的首屏目录页做起来。只要第一个场景跑通了,后面活动页、专题页、离线资源页,基本就是同一套思路往前推。

相关推荐
浮芷.2 小时前
Flutter 框架跨平台鸿蒙开发 - 智能家电故障诊断应用
运维·服务器·科技·flutter·华为·harmonyos·鸿蒙
小小ken2 小时前
鸿蒙模拟器提示:未开启hyper-v。运行模拟器需要开启hyper-v虚拟化支持。
华为·harmonyos·hyper-v·虚拟机
key_3_feng2 小时前
鸿蒙ArkTS电子书阅读应用开发方案
华为·harmonyos
HwJack202 小时前
HarmonyOS开发中@AnimatableExtend装饰器:把动画做成“乐高”,告别复制粘贴的痛
华为·harmonyos
浮芷.2 小时前
Flutter 框架跨平台鸿蒙开发 - 急救指南应用
学习·flutter·华为·harmonyos·鸿蒙
提子拌饭1332 小时前
液相色谱质谱联用(LC-MS)数据可视化引擎:基于鸿蒙Flutter的高精度色谱卡与多维峰值拟合架构
flutter·华为·信息可视化·开源·harmonyos·鸿蒙
Utopia^2 小时前
Flutter 框架跨平台鸿蒙开发 - 社交星系
flutter·华为·harmonyos
亘元有量-流量变现2 小时前
深度技术对比:Android、iOS、鸿蒙(HarmonyOS)权限管理全解析
android·ios·harmonyos·方糖试玩