【Electron-Playground系列】写一个录屏工具需要多长时间?

2,433 阅读4分钟

作者:ivyhaswell

关于它的介绍,我想从最近发生的一个实际例子说起:

最近组内开始给项目写文档,需要找一款录屏工具做视频和gif图。于是我们开始寻找录屏的工具,它们或截不到状态栏,或视频体积太大,或windows平台没有等等。最终找到一款能用的花了不少时间。我正好在做electron的项目,于是就寻思:用electron做一个简单的,跨平台的录屏工具,需要多久呢?

答案是二十分钟,数十行代码。

有兴趣可以跟着下面的步骤尝试一下:

(一) 创建一个录屏工具

  1. 首先创建一个目录,目录下安装electron;
yarn add -D electron
  1. 创建main.js,这里是主进程的代码;
const { app, BrowserWindow, globalShortcut } = require("electron");
const path = require("path");

app.on("ready", () => {
  const browserWindow = new BrowserWindow({
    webPreferences: { nodeIntegration: true, enableRemoteModule: true },
  });
  browserWindow.loadFile(path.resolve(__dirname, "index.html"));

  globalShortcut.register('CommandOrControl+Shift+R', () => browserWindow.webContents.send("StartRecording"))
  globalShortcut.register('CommandOrControl+Shift+S', () => browserWindow.webContents.send("StopRecording"))
});

app.on('will-quit', () => globalShortcut.unregisterAll())
  1. 创建index.html,用来引入渲染进程脚本
<h1>当前状态:<span id="status">空闲</span></h1>
<ol>
  <li>macOS下快捷键: Command+Shift+R 开始录制, Command+Shift+S停止录制;</li>
  <li>Windows下快捷键: Ctrl+Shift+R 开始录制, Ctrl+Shift+S停止录制;</li>
</ol>
<script src="./renderer.js"></script>
  1. 创建renderer.js,这里有主要的业务代码
const { desktopCapturer, remote, shell, ipcRenderer } = require("electron");
const path = require("path");
const fs = require("fs");

let mediaRecorder = null;
let chunks = []

async function start() {
    if (mediaRecorder) return;

    const sources = await desktopCapturer.getSources({ types: ["screen"] });
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: false,
      video: {
        mandatory: {
          chromeMediaSource: "screen",
          chromeMediaSourceId: sources[0].id,
        },
      },
    });

    mediaRecorder = new MediaRecorder(stream, { mimeType: "video/webm; codecs=vp9" });
    mediaRecorder.ondataavailable = (event) => {
      event.data.size > 0 && chunks.push(event.data);
    };

    mediaRecorder.start();
	  updateStatusText('录制中...')
}

function stop(){
  if(!mediaRecorder) return

  mediaRecorder.onstop = async () => {
    const blob = new Blob(chunks, { type: "video/webm" });
    const buffer = Buffer.from(await blob.arrayBuffer());
    const filePath = path.resolve(remote.app.getPath("downloads"), `${Date.now()}.webm`);

    fs.writeFile(filePath, buffer, () => {
      shell.openPath(filePath);
      mediaRecorder = null;
      chunks = []
    });
  };
  mediaRecorder.stop();
  updateStatusText('空闲')
}

function updateStatusText(text){
  const $statusElement = document.querySelector('#status')
  $statusElement.textContent = text
}

ipcRenderer.on("StartRecording", start);
ipcRenderer.on("StopRecording", stop);

然后就可以命令行启动应用

./node_modules/.bin/electron  main.js

使用也非常简单:

  1. Mac下使用快捷键 Command+Shift+R 开始录制,Command+Shift+S 停止录制;Windows的快捷键则为 Control+Shift+RControl+Shift+S
  2. 停止录制后会自动打开录制好的视频(视频默认保存在下载目录);

应用启动后长这样:

下面的动图就是使用这个demo录屏然后转换而成的:

(二) 工具代码解析

二十分钟和数十行代码的说法可能有些夸张,毕竟还有许多功能,如录制参数配置、格式转换、多平台打包等等都还没有实现;但并不妨碍能看出来,electron开发上手,确实挺简单。

在分析代码之前先来看看electron的一个基本概念:主进程和渲染进程。

  • 主进程通过BrowserWindow创建窗口,每个窗口对应一个渲染进程;
  • 渲染进程管理对应的web页面,BrowserWindow销毁后,相应的渲染进程也会终止;
  • 一个渲染进程的崩溃不会影响其他渲染进程;

为了理解这个概念,我们直接动手尝试一下:

首先在根目录添加一个main-process.js

const { app, BrowserWindow, dialog } = require("electron");
const path = require("path");

app.on("ready", () => {
  const win1 = new BrowserWindow({ x: 20, y: 20 });
  win1.loadURL("https://github.com");

  const win2 = new BrowserWindow({ x: 500, y: 20 });
  win2.loadURL("https://stackoverflow.com");

  const win3 = new BrowserWindow({
    x: 20,
    y: 500,
    webPreferences: { nodeIntegration: true },
  });
  win3.loadFile(path.resolve(__dirname, "crash-renderer.html"));

  win3.webContents.on('render-process-gone', async () => {
    await dialog.showMessageBoxSync(win3, {message: '进程已崩溃.', buttons: ['关闭']})
    win3.close()
  })
});

再添加一个crash-renderer.html

<script>
  setTimeout(() => {
    process.crash()
  }, 2000);
</script>

启动应用试试:./node_modules/.bin/electron main-process.js

在这里main-process.js创建了三个窗口,第一个打开了github,第二个打开了stackoverflow,第三个打开了本地的html文件。三个窗口分别对应三个渲染进程。

crash-renderer.html中我们执行了process.crash(),因此可以发现第三个窗口的进程在2秒后崩溃,而另外两个窗口github和stackoverflow依然正常。

回到录屏应用,它的基本功能结构设计是这样的:

反映到实现上,我们从main.js看起

先是注册了应用准备好之后和退出之前的方法

app.on('ready', () => {...});
app.on('will-quit', () => {...});

在ready事件中做了两件事,首先是创建窗口:

const browserWindow = new BrowserWindow({
  webPreferences: { nodeIntegration: true, enableRemoteModule: true },
});
browserWindow.loadFile(path.resolve(__dirname, "index.html"));

nodeIntegrationenableRemoteModule设置为true以便在渲染进程中使用node和remote模块,有时为了应用安全我们需要禁用这两个属性;

然后通过loadFile方法加载index.html

接下来是注册全局快捷键:

globalShortcut.register('CommandOrControl+Shift+R', () => browserWindow.webContents.send("StartRecording"))
globalShortcut.register('CommandOrControl+Shift+S', () => browserWindow.webContents.send("StopRecording"))

监听到快捷键按下后,通过ipc向渲染进程发送消息;

在renderer.js中,则会接收对应的ipc消息执行对应的方法:

ipcRenderer.on("StartRecording", start);
ipcRenderer.on("StopRecording", stop);

其中start方法用于执行录制:

  1. 通过desktopCapturer.getSources获取屏幕源,这里取其中第一个,通常为主屏幕;
  2. 通过navigator.mediaDevices.getUserMedia获取视频流;
  3. 创建mediaRecorder,通过mediaRecorder记录视频数据;
async function start() {
    // 已经录制中
    if (mediaRecorder) return;

    // 这里取屏幕源的第一个,通常为主屏幕
    const sources = await desktopCapturer.getSources({ types: ["screen"] });
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: false,
      video: {
        mandatory: {
          chromeMediaSource: "screen",
          chromeMediaSourceId: sources[0].id,
        },
      },
    });

    mediaRecorder = new MediaRecorder(stream, { mimeType: "video/webm; codecs=vp9" });
    mediaRecorder.ondataavailable = (event) => {
      event.data.size > 0 && chunks.push(event.data);
    };

    mediaRecorder.start();
	  updateStatusText('录制中...')
}

end方法结束录制并保存文件:

  1. 给mediaRecorder添加结束方法,调用mediaRecorder.stop结束录制;
  2. 结束后,取录制的数据chunks创建Blob对象;
  3. 将Blob对象转换成arrayBuffer,然后转换成buffer;
  4. 将buffer写入本地新建视频文件;
  5. 打开视频文件;
  6. 重置mediaRecorder和chunks;
function stop(){
  if(!mediaRecorder) return

  mediaRecorder.onstop = async () => {
    const blob = new Blob(chunks, { type: "video/webm" });
    const buffer = Buffer.from(await blob.arrayBuffer());
    const filePath = path.resolve(remote.app.getPath("downloads"), `${Date.now()}.webm`);

    fs.writeFile(filePath, buffer, () => {
      shell.openPath(filePath);
      mediaRecorder = null;
      chunks = []
    });
  };
  mediaRecorder.stop();
  updateStatusText('空闲')
}

大功告成。

(三) 一探Electron

我们再来回顾一下electron的官方自我介绍:用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。

通过上面的例子也能看的出来,编写的代码确实都没有超出这三驾马车(甚至没用到CSS)。对于开发过nodejs的前端同学来说,不需要看解析都能很容易理解这些代码。

是不是感觉,抛掉主进程和通信模块的话,开发体验就像是在开发一个集成了node环境的网页?

而electron本身也提供了快速打开一个链接或html文件的方法,甚至不需要写主进程的代码,比如:

electron https://github.com
electron index.html
electron /Users/username/Projects/electron-demo/index.html
...

这些方式都会让electron启动应用后打开一个窗口,并加载对应的网页链接或文件。

但如果只是单纯的网页套壳,我们用PWA不就好了吗?

因为electron能够提供更多的东西。

我们来看看electron的主体,它包括三个部分:

  1. Chromium:用于web内容的显示;
  2. Node.js:用于文件读写,操作系统等底层api交互;
  3. 自定义API:用来提供常用的系统操作需要的方法,比如设置菜单和托盘,控制窗口等;

官方文档自己也吐槽:使用Electron开发应用程序,就像使用Web界面构建Node.js应用程序,或通过无缝集成的Node.js构建网页一样。 其中web开发和Node.js想必很多前端同学已经很熟悉了,因此上手electron开发,更多的就是需要熟悉electron的开发模式及其提供的API。另外如果想要搭建一个大型一些,在公司使用的项目,还需要一些electron开发的工程化经验。

官方文档是一个很好的学习对象,但无法作为唯一的参考。从官方文档到周边库文档,文档说不清的自己去试,试不全的打开vscode源码参考考;遇着bug到github找issue,有时需要一路追溯到electron源码和chromium源码......

在其中学过的一些知识,踩过的一些坑,我们总结出了一个开源项目:

github.com/tal-tech/el…

项目中有我们总结了自己学习和踩坑的经验,参考了一些开源社区比较优秀的方案,做了这个项目,作为electron的快速学习和踩坑用。

目前最主要的功能,一是文档中内嵌代码都能直接运行,也可以直接在界面上修改代码运行,方便调整参数看效果;二是演练场,用于编写一些小的功能模块直接运行,目前只有基础模板,我们的目标是以后增加许多常用的功能模块,比如截图,比如消息通知,比如文件上传下载......等等等等,请拭目以待。

对项目有什么建议、想要什么功能、发现什么bug,都欢迎到Github提issue,我们的回复速度,超快的。

为了能更好学习electron,我们目前创作了一个系列,有兴趣可以看看

如果想看更完整的文档,请参考下面文档

Electron-Playground官方文档

github地址传送门:github.com/tal-tech/el…