Skip to content

开放数据域完整实现

预计阅读 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.json

game.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: 排行榜一片空白?

  1. 确认 game.jsonopenDataContext 配置了正确的目录路径。
  2. 确认开放数据域入口文件以 index.js 命名。
  3. 确认 sharedCanvas 在主域中通过 drawImage 绘制到了主 Canvas。
  4. 开放数据域需要真机测试,开发者工具中部分功能可能不显示

Q: 开放数据域加载资源失败?

开放数据域不能加载主域的图片/JSON 等资源,所有绘制素材需要:

  • 使用 Canvas 原生 API 绘制(圆、矩形、文字等)。
  • 在开放数据域内部通过 wx.createImage() 从网络 URL 加载。
  • 通过 wx.getFriendCloudStorage 返回的 avatarUrl 加载用户头像(wx.getUserInfo 已于 2021 年废弃,不再推荐使用)。

Q: 主域修改 sharedCanvas 尺寸无效?

必须在主域调用 wx.getOpenDataContext().canvas 后直接修改 width/height,并且需要给开放数据域发送 updateViewPort 消息通知其重新布局。

排行榜最佳实践
  1. 懒初始化 — 不要一进游戏就加载开放数据域,用户打开排行榜时才初始化。
  2. 视口裁剪 — 只绘制当前可见区域,减少 Canvas 绘制开销。
  3. 节流刷新 — 排行榜不需要 60fps,1s 刷新一次即可。
  4. 点击穿透 — 在 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)
  }
}
弱网降级原则
  1. 静默降级:不使用 wx.showToast 等模态提示打断用户——旧数据比没有数据好。
  2. 明确区分:UI 上清晰标示数据是否为缓存的旧数据,管理用户预期。
  3. 过期自动刷新:缓存过期后首次网络恢复即刷新,避免一直显示过期数据。
  4. 首次启动宽容:首次安装没有缓存是正常的,展示引导文案而非错误提示。

📝 课后练习

练习 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 个好友。你如何保证排行榜渲染不卡顿?

参考答案:

  1. 虚拟滚动 — 只渲染可视区域内的条目(约 10-15 条)
  2. 头像懒加载 — 进入可视区再加载头像
  3. 节流刷新 — 1 秒刷新一次而非 60fps
  4. Canvas 分层 — 静态背景和动态排行分开绘制
  5. 缓存头像 — 已加载的头像本地缓存,避免重复下载

用心学习,持续实践