大家好,我是鸿蒙Jack。本期我就不空讲概念了,直接以我的 BabyOne APP 中的背单词功能为例,聊聊 uniapp 里怎么把鸿蒙预加载真正接起来。
我这次要解决的不是"怎么调用一个 API"这么简单,而是一个完整链路的问题:应用安装后第一次打开要快,后面日常打开也要快,但我又不想把整本词书、所有详情页都硬塞进预加载里。最后我采用的是一套比较务实的方案:
安装预加载负责首开目录,周期性预加载负责后续目录更新,详情数据继续走普通接口或云函数兜底。
正式开始之前,先放两个官方入口,这两个链接建议先开着看:
- 预加载服务说明:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/cloudfoundation-prefetch-service
- 预加载服务开发流程:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/cloudfoundation-prefetch-devprocess
还有一个前提我先说清楚:本文以下示例以"数据来源为开发者服务器"为例来讲,这个能力是需要申请开通的。如果你走的是云开发,整体思路和业务层代码基本一样,只是 AGC 里把数据来源从开发者服务器换成云函数即可。
我为什么会用预加载
BabyOne 里有一个背单词模块。这个页面本身不复杂,首屏就是词书目录、词数统计、每本词书的几个预览词。问题也很典型:这些内容其实非常适合提前准备好,但如果每次都等页面打开后再请求接口,用户第一次进来还是会看到明显的等待。
这类页面最适合吃预加载红利,因为它有三个特点:
第一,首屏数据是静态展示数据,不是强实时数据。
第二,目录比详情轻,适合塞进预加载缓存。
第三,用户对"首开是不是马上有内容"非常敏感。
所以我没有把"整本词书详情"拿去做预加载,而是只把"词书目录 + 每本词书前三个预览词"做成预加载资源。这样做很重要,因为官方文档里写得很清楚,安装预加载缓存上限是 2MB,周期性预加载缓存上限是 3MB。如果你一上来就把大对象塞进去,后面很容易把预加载做成一个看起来高级、实际很脆的功能。
我的整体方案
我在 BabyOneAPP 里的链路是这样的:
AGC 预加载配置
开发者服务器预加载接口
安装预加载缓存
周期性预加载缓存
UniApp App.onLaunch
primeWordbookPrefetch
转存安装预加载结果到本地
registerPrefetchTask 注册周期任务
词书目录页
loadWordbookCatalog
开发者服务器普通接口兜底
词书详情页
loadWordbookBook
普通接口或云函数
这里有两个很关键的落地点。
第一个点,安装预加载的数据只能消费一次。我不想把这个机会浪费在某个随机页面打开上,所以我在 App.vue 的 onLaunch 里就先主动消费一次,然后把结果转存到本地缓存。这样词书目录页第一次真正打开时,数据还是随手可拿。
第二个点,周期性预加载不是"注册完马上有结果"。官方机制决定了它是系统周期拉取,所以它更适合做"后续打开时的缓存更新",而不是替代实时接口。
先把云侧接口想明白
我这次选的是"开发者服务器"作为预加载数据源。这个模式下,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 把 appId、token、params 等 query 参数带过来。我的这个目录接口没有做用户态差异化,所以直接忽略这些 query 参数就够了。如果你做的是个性化首页、个性化推荐,再去解析 token 和 params 更合适。
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 插件把 cloudResPrefetch 和 cloudFunction.call 收口。
App 启动阶段提前消费安装预加载。
页面层统一走业务服务,不关心底层命中来源。
预加载失效时,普通接口继续兜底。
这样做下来,整个功能的收益很明确:首开目录页更快,后续打开也更稳,而且不会因为预加载失效把页面直接搞挂。
如果你也在做 uniapp 的鸿蒙项目,我的建议是先别想着"全站预加载",先挑一个最典型的首屏目录页做起来。只要第一个场景跑通了,后面活动页、专题页、离线资源页,基本就是同一套思路往前推。