全部產品
Search
文件中心

Intelligent Media Services:接入白板

更新時間:Jun 17, 2025

本文旨在介紹如何快速整合網易雲信白板SDK並發布白板流。

開通白板服務

目前您可以通過兩種方式開通網易雲信白板服務:

  1. 網易雲信官網自行開通互動白板服務。

  2. 如果您的阿里雲ARTC消費可能超過5萬/月,也可以通過工單或郵件申請阿里雲側代為採購網易雲信互動白板服務,白板費用由客戶直接支付給阿里雲。如何提交工單,請參見聯絡我們

整合白板SDK

引入 SDK 檔案

下載sdk zip包,解壓後將g2檔案夾中的WhiteBoardSDK.jsToolCollection.jspptRenderer.js複製至您的工程靜態檔案夾下(一般是public檔案夾)或者發布至您的CDN上,然後在入口HTML檔案中通過<script>引入這三個JS檔案。

擷取互動白板AppKey與AppSecret

  1. 網易雲信控制台首頁的應用管理欄中找到建立的應用,單擊應用程式名稱

  2. 應用配置導覽列中,單擊AppKey管理頁簽。

  3. 查看並記錄該應用的AppKeyAppSecret

關於互動白板,更多配置請參見互動白板新手接入指南

如果您是由阿里雲側代為採購的,阿里雲側採購後會把對應的AppKey和AppSecret同步給您。

開發getAuthInfo介面

請將白板SDK的appkeyappSecret添加至您的服務端配置參數中,開發getAuthInfo介面,對appSecretcurTime等資料做SHA-1加密,返回相關資料給前端使用。

重要

本地例子是在Web頁面中做加密,僅為本地跑通樣本的做法,線上環境請勿泄露 appSecret。

初始化

首先,您需要在您的HTML代碼中,增加一個DIV元素作為白板容器。

<div id="whiteboard"></div>

然後,建立白板SDK執行個體,通過joinRoom介面加入房間。

const appKey = '應用appKey'; // 您可以在網易雲信控制台的應用下AppKey管理中擷取
const appSecret = '應用appSecret';
const nickname = '暱稱';
const uid = 123123;  //正整數, 應該小於Number.MAX_SAFE_INTEGER,同一uid多處登入會被互踢。如果需要多端同步,可以設定通過兩個不同的 uid 登入。

/**
* 該函數用於返回互動白板應用需要的auth資訊。
* 在需要時,互動白板sdk會調用該函數,該函數通過promise將auth交給sdk
*
* 下面代碼僅為demo,在實際開發時,請不要將appSecret儲存在用戶端,這可能會導致appSecret被竊取。實際開發時,可以使用getAuthInfo向伺服器請求Auth訊息,最後在promise中將auth資訊返回給sdk。
*/
function getAuthInfo() {
  const Nonce = 'xxxx';   //任意長度小於128位的隨機字串
  const curTime = Math.round((Date.now() / 1000)); //當前UTC時間戳記,從1970年1月1日0點0分0秒開始到現在的秒數
  const checksum = sha1(appSecret + Nonce + curTime);
  return Promise.resolve({
    nonce: Nonce,
    checksum: checksum,
    curTime: curTime,
  });
}

const sdk = WhiteBoardSDK.getInstance({
  appKey: appKey,
  nickname: nickname,     //非必須
  uid: uid,
  container: document.getElementById('whiteboard'),
  platform: 'web',
  record: false,   //是否開啟錄製
  getAuthInfo: getAuthInfo,
});

// channel任一字元串。不同端需要進入相同的channel才能夠互連
const channel = '821937123'
sdk.joinRoom({
  channel: channel,
  createRoom: true
})
.then((drawPlugin) => {
  // 允許編輯
  drawPlugin.enableDraw(true)
  // 設定畫筆顏色
  drawPlugin.setColor('rgb(243,0,0)')

  // 初始化工具列
  const toolCollection = ToolCollection.getInstance({
    /**
    * 工具列容器。應該和白板容器一致
    *
    * 注意工具列內子項目位置為絕對位置。因此,工具列外的容器應該設定定位為relative, absolute, 或者fixed。
    * 這樣,工具列才能夠正確的顯示在容器內部
    */
    container: document.getElementById('whiteboard'),
    handler: drawPlugin,
    options: {
      platform: 'web',
    }
  });
  
  // 顯示工具列
  toolCollection.show();
});

載入文檔

目前白板SDK支援載入、展示ppt, pptx, doc, docx, pdf等檔案,你可以通過左側邊欄中的檔案上傳控制項開啟,開啟檔案資產庫彈窗,在彈窗中管理檔案清單。

檔案清單持久化

白板SDK會將您的檔案清單存放在瀏覽器LocalStorage中,您關閉頁面後再次開啟仍能展示之前的檔案清單。但這個方案不能解決檔案清單持久化問題,如果您需要在不同裝置、不同瀏覽器上仍能展示之前的檔案清單,那麼您需要開發相關的服務端介面,並通過監聽docAdddocDelete等白板SDK事件,將列表資料儲存至服務端,初始化時調用白板SDK的setDefaultDocList等介面更新檔案清單。更多事件、介面說明,請參見白板SDK文檔

注意事項

目前白板SDK僅支援將白板canvas上的內容轉成視頻流,如果你在畫布中添加視頻、音頻等檔案,這部分內容無法通過白板推流共用。

發布白板流

白板SDK載入成功並加入房間後,可以通過getStream介面擷取視頻流,再通過自訂輸入功能發布。

// 白板SDK執行個體 joinRoom 成功後返回 drawPlugin 對象
const mediaStream = drawPlugin.getStream({
  width: 720,
  frameRate: 15,
});
const videoTrack = mediaStream.getVideoTracks()[0];
if (videoTrack) {
  try {
    await aliRtcEngine.startScreenShare({
      videoTrack, // 傳入自訂的視頻軌
    });
    console.log('推白板成功');
  } catch (error) {
    console.log('推白板失敗');
  }
} else {
  console.log('無白板 videoTrack');
}

整合樣本

前提

白板整合樣本是基於文檔快速開始中的快速體驗樣本擴充得到,請先跑通該樣本,再運行白板的整合樣本。

第一步:建立檔案

demo檔案夾中建立whiteboard.htmlwhiteboard.js兩個檔案,並將白板SDK的WhiteBoardSDK.jsToolCollection.jspptRenderer.js三個JS檔案也放入目錄中,目錄結構如下所示:

- demo
  - quick.html
  - quick.js
  - whiteboard.html
  - whiteboard.js
  - WhiteBoardSDK.js
  - pptRenderer.js
  - ToolCollection.js

第二步:編輯whiteboard.html

請把下方代碼複製粘貼進whiteboard.html並儲存。

展開查看whiteboard.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>aliyun-rtc-sdk whiteboard</title>
    <link rel="stylesheet" href="https://g.alicdn.com/code/lib/bootstrap/5.3.0/css/bootstrap.min.css" />
    <style>
      .video {
        display: inline-block;
        width: 320px;
        height: 180px;
        margin-right: 8px;
        margin-bottom: 8px;
        background-color: black;
      }
      .whiteboard {
        position: relative;
        width: 100%;
        height: 500px;
      }
    </style>
  </head>
  <body class="container">
    <h1 class="mt-2">aliyun-rtc-sdk 整合白板</h1>

    <div class="toast-container position-fixed top-0 end-0 p-3">
      <div id="loginToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">登入訊息</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="loginToastBody"></div>
      </div>

      <div id="onlineToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">使用者上線</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="onlineToastBody"></div>
      </div>

      <div id="offlineToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">使用者上線</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="offlineToastBody"></div>
      </div>

      <div id="screenToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">螢幕/白板訊息</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="screenToastBody"></div>
      </div>
    </div>

    <div class="row mt-3">
      <div class="col-8">
        <form id="loginForm">
          <div class="form-group mb-2">
            <label for="channelId" class="form-label">頻道號</label>
            <input class="form-control form-control-sm" id="channelId" />
          </div>
          <div class="form-group mb-2">
            <label for="userId" class="form-label">使用者ID</label>
            <input class="form-control form-control-sm" id="userId" />
          </div>
          <div class="mb-2">
            <button id="joinBtn" type="submit" class="btn btn-primary btn-sm">加入頻道</button>
            <button id="leaveBtn" type="button" class="btn btn-secondary btn-sm" disabled>離開頻道</button>
            <button id="screenBtn" type="button" class="btn btn-secondary btn-sm" disabled>推螢幕</button>
            <button id="boardBtn" type="button" class="btn btn-secondary btn-sm" disabled>推白板</button>
          </div>
        </form>
        <div id="whiteboard" class="whiteboard"></div>
      </div>
      <div class="col-4">
        <div>
          <h5>本地預覽</h5>
          <video
            id="localPreviewer"
            muted
            class="video"
          ></video>
        </div>
        <div>
          <h5>遠端使用者</h5>
          <div id="remoteVideoContainer"></div>
        </div>
      </div>
    </div>

    <script src="https://g.alicdn.com/code/lib/jquery/3.7.1/jquery.min.js"></script>
    <script src="https://g.alicdn.com/code/lib/bootstrap/5.3.0/js/bootstrap.min.js"></script>
    <script src="https://g.alicdn.com/apsara-media-box/imp-web-rtc/6.11.1/aliyun-rtc-sdk.js"></script>
    <script src="./WhiteBoardSDK.js"></script>
    <script src="./pptRenderer.js"></script>
    <script src="./ToolCollection.js"></script>
    <script src="./whiteboard.js"></script>
  </body>
</html>

第三步:編輯whiteboard.js

請把下方代碼複製粘貼進whiteboard.js ,並將aliyun-rtc-sdk應用IDAppKey,以及白板SDKappKeyappsecret粘貼進代碼指定變數中儲存。

展開查看whiteboard.js

function hex(buffer) {
  const hexCodes = [];
  const view = new DataView(buffer);
  for (let i = 0; i < view.byteLength; i += 4) {
    const value = view.getUint32(i);
    const stringValue = value.toString(16);
    const padding = '00000000';
    const paddedValue = (padding + stringValue).slice(-padding.length);
    hexCodes.push(paddedValue);
  }
  return hexCodes.join('');
}
async function generateToken(appId, appKey, channelId, userId, timestamp) {
  const encoder = new TextEncoder();
  const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);

  const hash = await crypto.subtle.digest('SHA-256', data);
  return hex(hash);
}

function showToast(baseId, message) {
  $(`#${baseId}Body`).text(message);
  const toast = new bootstrap.Toast($(`#${baseId}`));

  toast.show();
}

// 填入您的應用ID 和 AppKey
const appId = '';
const appKey = '';
AliRtcEngine.setLogLevel(0);
let aliRtcEngine;
const remoteVideoElMap = {};
const remoteVideoContainer = document.querySelector('#remoteVideoContainer');

function removeRemoteVideo(userId, type = 'camera') {
  const vid = `${type}_${userId}`;
  const el = remoteVideoElMap[vid];
  if (el) {
    aliRtcEngine.setRemoteViewConfig(null, userId, type === 'camera' ? 1: 2);
    el.pause();
    remoteVideoContainer.removeChild(el);
    delete remoteVideoElMap[vid];
  }
}

function listenEvents() {
  if (!aliRtcEngine) {
    return;
  }
  // 監聽遠端使用者上線
  aliRtcEngine.on('remoteUserOnLineNotify', (userId, elapsed) => {
    console.log(`使用者 ${userId} 加入頻道,耗時 ${elapsed} 秒`);
    // 這裡處理您的商務邏輯,如展示這個使用者的模組
    showToast('onlineToast', `使用者 ${userId} 上線`);
  });

  // 監聽遠端使用者下線
  aliRtcEngine.on('remoteUserOffLineNotify', (userId, reason) => {
    // reason 為原因碼,具體含義請查看 API 文檔
    console.log(`使用者 ${userId} 離開頻道,原因碼: ${reason}`);
    // 這裡處理您的商務邏輯,如銷毀這個使用者的模組
    showToast('offlineToast', `使用者 ${userId} 下線`);
    removeRemoteVideo(userId, 'camera');
    removeRemoteVideo(userId, 'screen');
  });

  aliRtcEngine.on('bye', code => {
    // code 為原因碼,具體含義請查看 API 文檔
    console.log(`bye, code=${code}`);
    // 這裡做您的處理業務,如退出通話頁面等
    showToast('loginToast', `您已離開頻道,原因碼: ${code}`);
  });

  aliRtcEngine.on('videoSubscribeStateChanged', (userId, oldState, newState, interval, channelId) => {
    // oldState、newState 類型均為AliRtcSubscribeState,值包含 0(初始化)、1(未訂閱)、2(訂閱中)、3(已訂閱)
    // interval 為兩個狀態之間的變化時間間隔,單位毫秒
    console.log(`頻道 ${channelId} 遠端使用者 ${userId} 訂閱狀態由 ${oldState} 變為 ${newState}`);
    const vid = `camera_${userId}`;
    // 處理樣本
    if (newState === 3) {
      const video = document.createElement('video');
      video.autoplay = true;
      // video.setAttribute(
      //   'style',
      //   'display: inline-block;width: 320px;height: 180px;background-color: black;margin-right: 8px;margin-bottom: 8px;'
      // );
      video.className = 'video';
      remoteVideoElMap[vid] = video;
      remoteVideoContainer.appendChild(video);
      // 第一個參數傳入 HTMLVideoElement
      // 第二個參數傳入遠端使用者識別碼
      // 第三個參數支援傳入 1 (預覽相機流)、2(預覽螢幕畫面分享流)
      aliRtcEngine.setRemoteViewConfig(video, userId, 1);
    } else if (newState === 1) {
      removeRemoteVideo(userId, 'camera');
    }
  });

  aliRtcEngine.on('screenShareSubscribeStateChanged', (userId, oldState, newState, interval, channelId) => {
    // oldState、newState 類型均為AliRtcSubscribeState,值包含 0(初始化)、1(未訂閱)、2(訂閱中)、3(已訂閱)
    // interval 為兩個狀態之間的變化時間間隔,單位毫秒
    console.log(`頻道 ${channelId} 遠端使用者 ${userId} 螢幕流的訂閱狀態由 ${oldState} 變為 ${newState}`);
    const vid = `screen_${userId}`;
    // 處理樣本    
    if (newState === 3) {
      const video = document.createElement('video');
      video.autoplay = true;
      video.className = 'video';
      remoteVideoElMap[vid] = video;
      remoteVideoContainer.appendChild(video);
      // 第一個參數傳入 HTMLVideoElement
      // 第二個參數傳入遠端使用者識別碼
      // 第三個參數支援傳入 1 (預覽相機流)、2(預覽螢幕畫面分享流)
      aliRtcEngine.setRemoteViewConfig(video, userId, 2);
    } else if (newState === 1) {
      removeRemoteVideo(userId, 'screen');
    }
  });
  
  // 推螢幕流狀態變化
  aliRtcEngine.on('screenSharePublishStateChanged', (oldState, newState, interval, channelId) => {
    // oldState、newState 類型均為AliRtcSubscribeState,值包含 0(初始化)、1(未發布)、2(發布中)、3(發行)
    // interval 為兩個狀態之間的變化時間間隔,單位毫秒
    console.log(`頻道 ${channelId} 本地螢幕流的推流狀態由 ${oldState} 變為 ${newState}`);
    if (oldState === 3 && newState === 1) {
      showToast('screenToast', '已停推螢幕流');
    }
  });
}

$('#loginForm').submit(async e => {
  // 防止表單預設提交動作
  e.preventDefault();
  const channelId = $('#channelId').val();
  const userId = $('#userId').val();
  const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;

  if (!channelId || !userId) {
    showToast('loginToast', '資料不完整');
    return;
  }

  aliRtcEngine = AliRtcEngine.getInstance();
  listenEvents();

  try {
    const token = await generateToken(appId, appKey, channelId, userId, timestamp);
    // 設定頻道模式,支援傳入字串 communication(通話模式)、interactive_live(互動模式)
    aliRtcEngine.setChannelProfile('communication');
    // 設定角色,互動模式時調用才生效
    // 支援傳入字串 interactive(互動角色,允許推拉流)、live(觀眾角色,僅允許拉流)
    // aliRtcEngine.setClientRole('interactive');
    // 加入頻道,參數 token、nonce 等一般有服務端返回
    await aliRtcEngine.joinChannel(
      {
        channelId,
        userId,
        appId,
        token,
        timestamp,
      },
      userId
    );
    showToast('loginToast', '加入頻道成功');
    $('#joinBtn').prop('disabled', true);
    $('#leaveBtn').prop('disabled', false);
    $('#boardBtn').prop('disabled', false);
    $('#screenBtn').prop('disabled', false);

    // 預覽
    aliRtcEngine.setLocalViewConfig('localPreviewer', 1);
  } catch (error) {
    console.log('加入頻道失敗', error);
    showToast('loginToast', '加入頻道失敗');
  }
});

$('#leaveBtn').click(async () => {
  Object.keys(remoteVideoElMap).forEach(vid => {
    const arr = vid.split('_');
    removeRemoteVideo(arr[1], arr[0]);
  });
  // 停止本地預覽
  await aliRtcEngine.stopPreview();
  // 離開頻道
  await aliRtcEngine.leaveChannel();
  // 銷毀執行個體
  aliRtcEngine.destroy();
  aliRtcEngine = undefined;
  $('#joinBtn').prop('disabled', false);
  $('#leaveBtn').prop('disabled', true);
  $('#boardBtn').prop('disabled', true);
  $('#screenBtn').prop('disabled', true);
  showToast('loginToast', '已離開頻道');
});

// 這裡填入您的網易雲信白板的 AppKey 和 AppSecret
// 僅限本地開發體驗,線上環境請勿露出 AppSecret
const boradAppKey = '';
const boradAppSecret = '';
const boradUid = Date.now(); // 易雲信白板要求是數字uid
const boradNickname = boradUid.toString();
const boradChannel = '821937123';

async function sha1(data) {
  // 將字串轉換為ArrayBuffer
  const buffer = new TextEncoder().encode(data);
  
  // 使用Crypto API計算雜湊值
  const digest = await crypto.subtle.digest('SHA-1', buffer);
  
  // 將ArrayBuffer轉換為十六進位字串
  const hashArray = Array.from(new Uint8Array(digest));
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
  
  return hashHex;
}

async function getAuthInfo() {
  const Nonce = 'xxxx';   //任意長度小於128位的隨機字串
  const curTime = Math.round((Date.now() / 1000)); //當前UTC時間戳記,從1970年1月1日0點0分0秒開始到現在的秒數
  const checksum = await sha1(boradAppSecret + Nonce + curTime);
  return {
    nonce: Nonce,
    checksum: checksum,
    curTime: curTime
  };
}

// 建立白板執行個體
const boardIns = WhiteBoardSDK.getInstance({
  appKey: boradAppKey,
  nickname: boradNickname,     //非必須
  uid: boradUid,
  container: document.getElementById('whiteboard'),
  platform: 'web',
  record: false,   //是否開啟錄製
  getAuthInfo: getAuthInfo
});
let drawPluginIns;

// 登入白板房間
boardIns.joinRoom({
  channel: boradChannel,
  createRoom: true
})
.then((drawPlugin) => {
  drawPluginIns = drawPlugin;
  // 允許編輯
  drawPlugin.enableDraw(true);
  // 設定畫筆顏色
  drawPlugin.setColor('rgb(243,0,0)');

  // 初始化工具列
  const toolCollection = ToolCollection.getInstance({
      container: document.getElementById('whiteboard'),
      handler: drawPlugin,
      options: {
          platform: 'web'
      }
  });
  toolCollection.addOrSetTool({
    position: 'left',
    insertAfterTool: 'pan',
    item: {
      tool: 'uploadCenter',
      hint: '上傳文檔',
      supportPptToH5: true,
      supportDocToPic: true,
      supportUploadMedia: false, // 關閉上傳多媒體檔案
      supportTransMedia: false, // 關閉轉碼多媒體檔案
    },
  });
  toolCollection.removeTool({ name: 'image' });
  
  // 顯示工具列
  toolCollection.show();

  // 監聽文檔事件
  toolCollection.on('docAdd', (newDocs, allDocs) => {
    console.log('add allDocs->', newDocs, allDocs);
    // 您可以在 docAdd 事件回調中將文檔資料上傳至您的服務端
    // 建議:服務端通過白板 channel 維度去儲存
  });
  toolCollection.on('docDelete', (newDocs, allDocs) => {
    console.log('delete allDocs->', newDocs, allDocs);
    // 您可以在 docDelete 事件回調中將文檔資料上傳至您的服務端
    // 建議:服務端通過白板 channel 維度去儲存
  });

  // 初始化後從服務端中擷取該 channel 的檔案清單,並更新至白板SDK中
  // fetch('/docList', (list) => {
  //   toolCollection.setDefaultDocList(list);
  // });
});

$('#screenBtn').click(async() => {
  if (!aliRtcEngine) {
    showToast('screenToast', 'sdk 未準備好');
    return;
  }
  try {
    await aliRtcEngine.startScreenShare();
    showToast('screenToast', '推螢幕成功');
  } catch (error) {
    showToast('screenToast', '推螢幕失敗');
  }
});

$('#boardBtn').click(async() => {
  if (!aliRtcEngine || !drawPluginIns) {
    showToast('screenToast', 'sdk 未準備好');
    return;
  }
  const mediaStream = drawPluginIns.getStream({
    width: 720,
    frameRate: 15,
  });
  const videoTrack = mediaStream.getVideoTracks()[0];
  if (videoTrack) {
    try {
      await aliRtcEngine.startScreenShare({
        videoTrack,
      });
      showToast('screenToast', '推白板成功');
    } catch (error) {
      showToast('screenToast', '推白板失敗');
    }
  } else {
    showToast('screenToast', '無白板 videoTrack');
  }
});

第四步:運行體驗

  1. 在終端中進入demo檔案夾,然後執行http-server -p 8080,啟動一個 HTTP 服務。

  2. 瀏覽器中建立標籤頁,訪問localhost:8080/quick.html作為普通使用者,在介面上填入頻道ID使用者ID ,單擊加入頻道

  3. 瀏覽器中再建立一個標籤頁,訪問localhost:8080/whiteboard.html作為白板,在介面上填入與上一步相同的頻道 ID另一個使用者ID,單擊加入頻道

  4. 這時介面上將自動訂閱另一個使用者的媒體流,在whiteboard.html單擊按鍵推白板後,quick.html將會自動訂閱到whiteboard.html上的白板內容。