主题切换
开放数据域完整实现
预计阅读 25 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0(最低兼容 ≥ 2.30.0) · 微信开发者工具:稳定版 1.06+
开放数据域是微信小游戏实现好友排行榜的核心机制。它运行在独立 JS 环境中,与主域隔离,专门处理好友关系链数据。本章带你从零实现一个完整的排行榜系统。
开放数据域架构
┌─────────────────────────────────────────────────┐
│ 微信客户端 │
│ │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ 主域(逻辑域) │ │ 开放数据域 │ │
│ │ │ │ (独立 JS 环境) │ │
│ │ postMessage ────►│ │ │ │
│ │ ◄─── sharedCanvas 往返 │ │
│ │ │ │ 好友数据拉取 │ │
│ │ 游戏主逻辑 │ │ 排行榜绘制 │ │
│ │ Canvas 渲染 │ │ sharedCanvas 输出 │ │
│ └──────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────┘核心要点:
- 开放数据域只能访问好友关系链数据,不能调用普通 wx API。
- 开放数据域与主域通过
postMessage单向通信(主域 → 开放域)。 - 开放数据域的渲染结果通过
sharedCanvas传递给主域。 - 开放数据域不能加载主域图片/资源,所有绘制素材需自行提供或使用 Canvas 原生绘制。
项目结构
minigame/
├── game.js # 主域入口
├── game.json
├── openDataContext/ # 开放数据域目录
│ ├── index.js # 开放数据域入口
│ └── style.js # 排行榜渲染样式
└── project.config.jsongame.json 中配置开放数据域工作目录:
json
{
"deviceOrientation": "portrait",
"openDataContext": "openDataContext",
"subpackages": []
}主域通信
发送消息到开放数据域
typescript
// 主域:mainDomain.ts
class RankingManager {
private openDataContext = wx.getOpenDataContext()
private sharedCanvas: WechatMinigame.Canvas
constructor() {
this.sharedCanvas = this.openDataContext.canvas
// sharedCanvas 默认宽度 500, 高度会根据设备缩放比自动调整
}
/** 获取 sharedCanvas 的公共方法 */
getSharedCanvas(): WechatMinigame.Canvas {
return this.sharedCanvas
}
/** 显示排行榜 */
showRanking(rankingKey: string = 'totalScore'): void {
this.openDataContext.postMessage({
type: 'updateViewPort',
box: {
x: 20,
y: 100,
width: this.sharedCanvas.width - 40,
height: this.sharedCanvas.height - 120,
},
})
this.openDataContext.postMessage({
type: 'show',
key: rankingKey,
})
}
/** 向开放数据域上传成绩 */
uploadScore(score: number): void {
wx.setUserCloudStorage({
KVDataList: [{ key: 'totalScore', value: String(score) }],
success: () => console.log('成绩上传成功'),
fail: (err) => console.error('成绩上传失败', err),
})
}
/** 关闭排行榜 */
hideRanking(): void {
this.openDataContext.postMessage({ type: 'hide' })
}
}主域渲染 sharedCanvas
在主游戏循环中,将 sharedCanvas 绘制到主 Canvas:
typescript
// 主域 game.js 中 — 注意:canvas 需先通过 wx.createCanvas() 创建
const canvas = wx.createCanvas()
const ctx = canvas.getContext('2d')
const ranking = new RankingManager()
let rankingVisible = false // 排行榜可见性状态
function gameLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// ... 渲染游戏画面 ...
// 在 UI 层绘制排行榜
if (rankingVisible) {
const sc = ranking.getSharedCanvas() // 通过公共方法获取 sharedCanvas
ctx.drawImage(sc, 0, 0, sc.width, sc.height)
}
requestAnimationFrame(gameLoop)
}sharedCanvas 尺寸配置
sharedCanvas 的物理像素默认会随设备 pixelRatio 缩放。建议在初始化时设置:
typescript
const { screenWidth, pixelRatio } = wx.getSystemInfoSync()
ranking.sharedCanvas.width = screenWidth * pixelRatio
ranking.sharedCanvas.height = screenHeight * pixelRatio * 0.6 // 排行榜占 60%开放数据域实现
入口文件
openDataContext/index.js:
javascript
// 开放数据域入口
const sharedCanvas = wx.getSharedCanvas()
const ctx = sharedCanvas.getContext('2d')
let visible = false
let rankingKey = 'totalScore'
// 监听来自主域的消息
wx.onMessage((data) => {
switch (data.type) {
case 'updateViewPort':
// 根据主域传入的视口调整绘制区域
viewPort = data.box
break
case 'show':
visible = true
rankingKey = data.key
fetchAndRender()
break
case 'hide':
visible = false
clearCanvas()
break
}
})
function clearCanvas() {
ctx.clearRect(0, 0, sharedCanvas.width, sharedCanvas.height)
}
function fetchAndRender() {
wx.getFriendCloudStorage({
keyList: [rankingKey],
success: (res) => {
const sorted = res.data
.filter((user) => user.KVDataList?.length > 0)
.sort((a, b) => {
const getScore = (user) => {
const kv = user.KVDataList.find((k) => k.key === rankingKey)
return Number(kv?.value) || 0
}
return getScore(b) - getScore(a)
})
renderRanking(sorted)
},
fail: (err) => {
console.error('拉取好友数据失败', err)
renderError()
},
})
}排行榜渲染
javascript
// openDataContext/renderer.js
const ROW_HEIGHT = 80
const AVATAR_SIZE = 50
let viewPort = { x: 0, y: 0, width: 500, height: 600 }
function renderRanking(users) {
const { x: startX, y: startY, width, height } = viewPort
ctx.clearRect(startX, startY, width, height)
// 标题
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 24px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('🏆 好友排行榜', startX + width / 2, startY + 40)
// 每一条记录
users.slice(0, 50).forEach((user, index) => {
const y = startY + 80 + index * ROW_HEIGHT
const kv = user.KVDataList.find((k) => k.key === rankingKey)
const score = kv ? Number(kv.value) : 0
// 行背景
const isSelf = user.isSelfUser
ctx.fillStyle = isSelf ? 'rgba(0, 229, 255, 0.15)' : 'rgba(255, 255, 255, 0.05)'
ctx.fillRect(startX + 10, y, width - 20, ROW_HEIGHT - 4)
// 排名
ctx.fillStyle = index < 3 ? '#ffb800' : '#a0a0b8'
ctx.font = 'bold 18px sans-serif'
ctx.textAlign = 'center'
const medals = ['🥇', '🥈', '🥉']
const rankText = index < 3 ? medals[index] : `${index + 1}`
ctx.fillText(rankText, startX + 35, y + 45)
// 头像(圆形裁剪)
if (user.avatarUrl) {
const avatarImg = wx.createImage()
avatarImg.src = user.avatarUrl
avatarImg.onload = () => {
ctx.save()
ctx.beginPath()
ctx.arc(startX + 85, y + 40, AVATAR_SIZE / 2, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(avatarImg, startX + 60, y + 15, AVATAR_SIZE, AVATAR_SIZE)
ctx.restore()
}
}
// 昵称
ctx.fillStyle = '#e8e8f0'
ctx.font = '16px sans-serif'
ctx.textAlign = 'left'
const displayName = user.nickname.length > 8 ? user.nickname.slice(0, 8) + '...' : user.nickname
ctx.fillText(displayName, startX + 120, y + 35)
// 分数
ctx.fillStyle = isSelf ? '#00e5ff' : '#a0a0b8'
ctx.font = 'bold 16px sans-serif'
ctx.textAlign = 'right'
ctx.fillText(score.toLocaleString(), startX + width - 30, y + 45)
})
}常见问题与解决
Q: 排行榜一片空白?
- 确认
game.json中openDataContext配置了正确的目录路径。 - 确认开放数据域入口文件以
index.js命名。 - 确认
sharedCanvas在主域中通过drawImage绘制到了主 Canvas。 - 开放数据域需要真机测试,开发者工具中部分功能可能不显示。
Q: 开放数据域加载资源失败?
开放数据域不能加载主域的图片/JSON 等资源,所有绘制素材需要:
- 使用 Canvas 原生 API 绘制(圆、矩形、文字等)。
- 在开放数据域内部通过
wx.createImage()从网络 URL 加载。 - 通过
wx.getFriendCloudStorage返回的avatarUrl加载用户头像(wx.getUserInfo已于 2021 年废弃,不再推荐使用)。
Q: 主域修改 sharedCanvas 尺寸无效?
必须在主域调用 wx.getOpenDataContext().canvas 后直接修改 width/height,并且需要给开放数据域发送 updateViewPort 消息通知其重新布局。
排行榜最佳实践
- 懒初始化 — 不要一进游戏就加载开放数据域,用户打开排行榜时才初始化。
- 视口裁剪 — 只绘制当前可见区域,减少 Canvas 绘制开销。
- 节流刷新 — 排行榜不需要 60fps,1s 刷新一次即可。
- 点击穿透 — 在
sharedCanvas上方叠加透明按钮区域实现排行榜上的点击交互。
触摸交互与点击事件
开放数据域的 sharedCanvas 只是被渲染到主域 Canvas 上的一个离屏画布——要响应触摸事件,需要主域转发。
实现流程
用户触摸屏幕
→ 主域 wx.onTouchStart 捕获坐标
→ 判断触摸点是否在开放数据域 Canvas 范围内
→ 通过 postMessage 将坐标发给开放数据域
→ 开放数据域根据坐标判断点击了哪个排名项代码实现
typescript
// 主域代码 — 转发触摸事件
function setupOpenDataTouch(): void {
const openDataContext = wx.getOpenDataContext()
const sharedCanvas = openDataContext.canvas
// 开放数据域 Canvas 在主域中的布局信息
const layout = {
x: 0,
y: 200,
width: sharedCanvas.width,
height: sharedCanvas.height,
}
wx.onTouchStart((e) => {
const touch = e.touches[0]
const touchX = touch.clientX
const touchY = touch.clientY
// 判断是否点击在开放数据域区域内
if (
touchX >= layout.x &&
touchX <= layout.x + layout.width &&
touchY >= layout.y &&
touchY <= layout.y + layout.height
) {
// 转发到开放数据域(坐标需要减去偏移,转为开放域本地坐标)
openDataContext.postMessage({
type: 'touch',
x: touchX - layout.x,
y: touchY - layout.y,
})
}
})
}javascript
// 开放数据域代码 — 处理点击
wx.onMessage((msg) => {
if (msg.type === 'touch') {
const touchX = msg.x
const touchY = msg.y
// 检查点击了哪个排名项(假设每项高度 80px,从 y=0 开始)
const itemHeight = 80
const clickedIndex = Math.floor(touchY / itemHeight)
if (clickedIndex >= 0 && clickedIndex < rankings.length) {
const player = rankings[clickedIndex]
console.log(`点击了第 ${clickedIndex + 1} 名: ${player.nickname}`)
// 可以高亮显示或展开详情
}
}
})点击反馈优化
- 视觉反馈:在触摸事件中重绘开放数据域 Canvas,高亮被点击的行。
- 双指滚动:排行榜支持滑动浏览时,区分「滑动」和「点击」(通过比较 touchStart 和 touchEnd 的距离)。
多排行榜切换
大多数排行榜游戏支持多个排行榜标签——好友榜、世界榜、周榜、总榜等。
实现方案
javascript
// 开放数据域内 — 排行榜切换逻辑
const RANKING_TABS = [
{ key: 'friend_weekly', label: '好友周榜' },
{ key: 'friend_total', label: '好友总榜' },
{ key: 'global_weekly', label: '世界周榜' },
{ key: 'global_total', label: '世界总榜' },
]
let currentTab = 'friend_weekly'
let cachedRankings = {} // 缓存不同榜单数据,减少重复请求
// 主域通过 postMessage 发送切换指令
wx.onMessage((msg) => {
switch (msg.type) {
case 'switchTab':
currentTab = msg.tab
loadRankings(currentTab)
break
}
})
async function loadRankings(tabKey: string): Promise<void> {
const tab = RANKING_TABS.find((t) => t.key === tabKey)
if (!tab) return
// 分类获取数据
if (tabKey.startsWith('friend')) {
const data = await wx.getFriendCloudStorage({ keyList: ['score'] })
rankings = parseAndSortFriendData(data, tabKey.includes('weekly') ? 'weekly' : 'total')
} else {
// 世界榜通过云函数获取(开放数据域无直接 API)
// 由主域通过云函数获取后 postMessage 传入
}
renderRankings()
}
// 主域代码 — 排行榜 Tab 切换 UI
function createRankingTabs(): void {
RANKING_TABS.forEach((tab, index) => {
const tabX = 50 + index * 160
// 绘制 Tab 按钮(在主域 Canvas 上)
ctx.fillStyle = currentTab === tab.key ? '#00ffff' : '#666'
ctx.fillText(tab.label, tabX, 170)
})
// 点击检测
wx.onTouchStart((e) => {
const touch = e.touches[0]
RANKING_TABS.forEach((tab, index) => {
const tabX = 50 + index * 160
if (touch.clientX >= tabX && touch.clientX <= tabX + 140 && touch.clientY >= 155 && touch.clientY <= 180) {
openDataContext.postMessage({ type: 'switchTab', tab: tab.key })
}
})
})
}性能优化
- 缓存策略:好友排行榜数据不会频繁变化,缓存 30 秒再刷新即可。
- 分页加载:如果好友数超过 100 人,使用虚拟滚动方式只渲染可见区域。
离线/弱网降级
在弱网或离线环境下,wx.getFriendCloudStorage 可能超时或失败。提供缓存的旧数据和友好的降级提示非常重要。
降级策略
javascript
// 开放数据域内 — 带缓存的排行榜加载
const CACHE_KEY = 'ranking_cache'
const CACHE_EXPIRY = 5 * 60 * 1000 // 5 分钟缓存
async function loadRankingsWithFallback(): Promise<void> {
let dataLoaded = false
try {
// 设置 5 秒超时
// ⚠️ wx.getFriendCloudStorage 使用回调模式,不返回 Promise,需手动包装
const result = await Promise.race([
new Promise((resolve, reject) => {
wx.getFriendCloudStorage({
keyList: ['score', 'level'],
success: resolve,
fail: reject
})
}),
timeout(5000),
])
if (result) {
rankings = parseAndSortData(result)
// 成功加载,更新缓存
saveToCache(CACHE_KEY, {
data: rankings,
timestamp: Date.now(),
})
dataLoaded = true
}
} catch (err) {
console.warn('好友数据加载失败,尝试使用缓存', err)
}
// 降级:使用缓存数据
if (!dataLoaded) {
const cached = loadFromCache(CACHE_KEY)
if (cached) {
rankings = cached.data
isStaleData = true // 标记为旧数据,UI 上展示提示
console.log('使用缓存排行榜数据',
`(${Math.floor((Date.now() - cached.timestamp) / 60000)} 分钟前)`)
} else {
// 连缓存也没有——展示空状态
rankings = []
showEmptyState = true
}
}
renderRankings()
}
// 超时辅助函数
function timeout(ms: number): Promise<never> {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), ms)
)
}
// 简单存储(开放数据域中 localStorage 可用)
function saveToCache(key: string, data: any): void {
try {
wx.setStorageSync(key, JSON.stringify(data))
} catch { /* 存储满则放弃 */ }
}
function loadFromCache(key: string): any | null {
try {
const raw = wx.getStorageSync(key)
if (!raw) return null
const parsed = JSON.parse(raw)
// 检查是否过期
if (Date.now() - parsed.timestamp > CACHE_EXPIRY) return null
return parsed
} catch {
return null
}
}降级 UI 提示
javascript
// 在渲染排行榜时展示降级状态
function renderRankings(): void {
const ctx = sharedCanvas.getContext('2d')
ctx.clearRect(0, 0, sharedCanvas.width, sharedCanvas.height)
if (showEmptyState) {
// 空状态 — 无数据也无缓存
ctx.fillStyle = '#999'
ctx.font = '28px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('暂无排行榜数据', sharedCanvas.width / 2, sharedCanvas.height / 2)
ctx.font = '20px sans-serif'
ctx.fillText('请检查网络连接后重试', sharedCanvas.width / 2, sharedCanvas.height / 2 + 40)
return
}
// 正常渲染排行榜...
renderNormalRankings(ctx)
// 如果是旧数据,在顶部显示提示
if (isStaleData) {
ctx.fillStyle = 'rgba(255, 165, 0, 0.8)'
ctx.font = '20px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('⚠ 网络异常,显示缓存数据', sharedCanvas.width / 2, 30)
}
}弱网降级原则
- 静默降级:不使用
wx.showToast等模态提示打断用户——旧数据比没有数据好。 - 明确区分:UI 上清晰标示数据是否为缓存的旧数据,管理用户预期。
- 过期自动刷新:缓存过期后首次网络恢复即刷新,避免一直显示过期数据。
- 首次启动宽容:首次安装没有缓存是正常的,展示引导文案而非错误提示。
📝 课后练习
练习 1:开放数据域通信
题目: 写出主域向开放数据域发送"显示周排行榜"消息的代码,以及开放数据域接收并处理该消息的代码。
参考答案:
javascript
// 主域
const openDataContext = wx.getOpenDataContext()
openDataContext.postMessage({ type: 'showRanking', scope: 'weekly', key: 'score' })
// 开放数据域
wx.onMessage((data) => {
if (data.type === 'showRanking' && data.scope === 'weekly') {
wx.getFriendCloudStorage({
keyList: [data.key],
success: (res) => {
const sorted = res.data.sort((a, b) => {
const aVal = a.KVDataList.find((kv) => kv.key === data.key)?.value || 0
const bVal = b.KVDataList.find((kv) => kv.key === data.key)?.value || 0
return bVal - aVal
})
renderRanking(sorted)
},
})
}
})练习 2:排行榜性能优化
题目: 排行榜列表有 1000 个好友。你如何保证排行榜渲染不卡顿?
参考答案:
- 虚拟滚动 — 只渲染可视区域内的条目(约 10-15 条)
- 头像懒加载 — 进入可视区再加载头像
- 节流刷新 — 1 秒刷新一次而非 60fps
- Canvas 分层 — 静态背景和动态排行分开绘制
- 缓存头像 — 已加载的头像本地缓存,避免重复下载