作者:钟离,酷家乐 PC 客户端负责人
原文地址:https://webfe.kujiale.com/electron-ku-jia-le-ke-hu-duan-kai-fa-shi-jian-fen-xiang-jin-cheng-tong-xin/
酷家乐客户端:下载地址 https://www.kujiale.com/activity/136
文章背景:在酷家乐客户端在 V12 改版成功后,我们积累了许多的宝贵的经验和最佳实践。前端社区里关于 Electron 知识相对较少,因此希望将这些内容以系列文章的形式分享出来。
系列文章:
- 【Electron】酷家乐客户端开发实践分享 — 入坑篇
- 【Electron】酷家乐客户端开发实践分享 — 软件自动更新
- 【Electron】酷家乐客户端开发实践分享 — 浏览器启动客户端
- 【Electron】酷家乐客户端开发实践分享 — 进程通信
- 【Electron】酷家乐客户端开发实践分享 — 下载管理器
- 不定期更新 …
背景
打开酷家乐客户端,可以在左下角的更多菜单中找到下载管理这个功能,今天我们就来看看在 Electron 中如何实现一个下载管理器。
如何触发下载行为
由于 Electron 渲染层是基于 chromium 的,触发下载的逻辑和 chromium 是一致的,页面中的 a 标签或者 js 跳转等等行为都可能触发下载,具体视访问的资源而定。什么样的资源会触发浏览器的下载行为呢?
- response header 中的
Content-Disposition
为 attachment。参考 MDN Content-Disposition - response header 中的
Content-Type
,是浏览器无法直接打开的文件类型,例如application/octet-stream
,此时取决于浏览器的具体实现了。例子: IE 无法打开 pdf 文件,chrome 可以直接打开 pdf 文件,因此 pdf 类型的 url 在 chrome 上可以直接打开,而在 IE 下会触发下载行为。
在 Electron 中还有一种方法可以触发下载:webContents.download。相当于直接调用 chromium 底层的下载逻辑,忽略 headers 中的那些判断,直接下载。
上述两种下载行为,都会触发 session 的 will-download 事件,在这里可以获取到关键的 downloadItem 对象
整体流程
设置文件路径
如果不做任何处理的话,触发下载行为时 Electron 会弹出一个系统 dialog,让用户来选择文件存放的目录。这个体验并不好,因此我们首先需要把这个系统 dialog 去掉。使用 downloadItem.savePath
即可。
// Set the save path, making Electron not to prompt a save dialog.
downloadItem.setSavePath('/tmp/save.pdf');
为文件设置默认下载路径,就需要考虑文件名重复的情况,一般来说会使用文件名自增的逻辑,例如:test.jpg、test.jpg(1)这种格式。文件默认存放目录,也是一个问题,我们统一使用 app.getPath('downloads')
作为文件下载目录。为了用户体验,后续提供修改文件下载目录功能即可。
// in main.js 主进程中
const {session} = require('electron');
session.defaultSession.on('will-download', async (event, item) => {const fileName = item.getFilename();
const url = item.getURL();
const startTime = item.getStartTime();
const initialState = item.getState();
const downloadPath = app.getPath('downloads');
let fileNum = 0;
let savePath = path.join(downloadPath, fileName);
// savePath 基础信息
const ext = path.extname(savePath);
const name = path.basename(savePath, ext);
const dir = path.dirname(savePath);
// 文件名自增逻辑
while (fs.pathExistsSync(savePath)) {
fileNum += 1;
savePath = path.format({
dir,
ext,
name: `${name}(${fileNum})`,
});
}
// 设置下载目录,阻止系统 dialog 的出现
item.setSavePath(savePath);
// 通知渲染进程,有一个新的下载任务
win.webContents.send('new-download-item', {
savePath,
url,
startTime,
state: initialState,
paused: item.isPaused(),
totalBytes: item.getTotalBytes(),
receivedBytes: item.getReceivedBytes(),});
// 下载任务更新
item.on('updated', (e, state) => { // eslint-disable-line
win.webContents.send('download-item-updated', {
startTime,
state,
totalBytes: item.getTotalBytes(),
receivedBytes: item.getReceivedBytes(),
paused: item.isPaused(),});
});
// 下载任务完成
item.on('done', (e, state) => { // eslint-disable-line
win.webContents.send('download-item-done', {
startTime,
state,
});
});
});
现在触发下载行为,文件就已经会下载到 Downloads
目录了,文件名带有自增逻辑。同时,对下载窗口发送了关键事件,下载窗口可以根据这些事件和数据,创建、更新下载任务。
上述步骤在渲染进程使用 remote 实现会有问题,无法获取到实时的下载数据。因此建议在主进程实现。
下载记录
下载功能需要缓存下载历史在本地,下载历史的数据比较多,因此我们使用 nedb 作为本地数据库。
// 初始化 nedb 数据库
const db = nedbStore({filename, autoload: true});
ipcRenderer.on('new-download-item', (e, item) => {
// 数据库新增一条新纪录
db.insert(item);
// UI 中新增一条下载任务
this.addItem(item);
})
// 更新下载窗口的任务进度
ipcRenderer.on('download-item-updated', (e, item) => {this.updateItem(item)
})
// 下载结束,更新数据
ipcRenderer.on('download-item-done', (e, item) => {
// 更新数据库
db.update(item);
// 更新 UI 中下载任务状态
this.updateItem(item);
});
此时本地数据库中的数据,是这样的:
{"paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads/ 酷家乐装修网 - 保利金色佳苑 - 户型图.jpg","startTime":1560415098.731598,"state":"completed","totalBytes":236568,"url":"https://qhtbdoss.kujiale.com/fpimgnew/prod/3FO4EGX11S9S/op/LUBAVDQKN4BE6AABAAAAACY8.jpg?kpm=9V8.32a74ad82d44e7d0.3dba44f.1560415094020","_id":"6AorFZvpI0N8Yzw9"}
{"paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads/Kujiale-12.0.2-stable(1).dmg","startTime":1560415129.488072,"state":"progressing","totalBytes":80762523,"url":"https://qhstaticssl.kujiale.com/download/kjl-software12/Kujiale-12.0.2-stable.dmg?timestamp=1560415129351","_id":"YAeWIy2xoeWTw0Ht"}
{"paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads/ 酷家乐装修网 - 保利金色佳苑 - 户型图(1).jpg","startTime":1560418413.240669,"state":"progressing","totalBytes":236568,"url":"https://qhtbdoss.kujiale.com/fpimgnew/prod/3FO4EGX11S9S/op/LUBBLFYKN4BE6AABAAAAADY8.jpg?kpm=9V8.32a74ad82d44e7d0.3dba44f.1560418409875","_id":"obFLotKillhzTw09"}
{"paused":false,"receivedBytes":0,"savePath":"/Users/ww/Downloads/ 酷家乐装修网 - 保利金色佳苑 - 户型图(1).jpg","startTime":1560418413.240669,"state":"completed","totalBytes":236568,"url":"https://qhtbdoss.kujiale.com/fpimgnew/prod/3FO4EGX11S9S/op/LUBBLFYKN4BE6AABAAAAADY8.jpg?kpm=9V8.32a74ad82d44e7d0.3dba44f.1560418409875","_id":"obFLotKillhzTw09"}
在渲染进程初始化的时候,需要读取下载记录,数据按下载时间倒序。读取数量需要做一下限制,否则会影响性能,暂时限制 50 条。
// 渲染进程中
const db = nedbStore({filename, autoload: true});
// 读取历史数据
const downloadHistory = await db.cfind({}).sort({startTime: -1,}).limit(50).exec()
.catch(err => logger.error(err));
if (downloadHistory) {this.setList(downloadHistory.map((d) => {
const item = d;
// 历史记录中,只有需要未完成和完成两个状态
if (item.state !== 'completed') {item.state = 'cancelled';}
return item;
}));
}
自定义下载目录
默认下载目录在 Electron 默认为本机上的 Downloads
目录,提供用户设置下载目录的功能,就需要在本地缓存用户自定义的下载目录。这种基础配置我们使用 electron-store 来实现
// in config.json
{"downloadsPath": "/Users/ww/Downloads/ 归档"}
在窗口初始化的时候,检查缓存中是否有自定义下载目录,如果有则更改 app 的默认下载目录
componentDidMount() {const downloadsPath = store.get('downloadsPath');
if (downloadsPath) {app.setPath('downloads', downloadsPath);
// app.getPath('downloads'); -> /Users/ww/Downloads/ 归档
}
}
用户点击更换下载目录,此时需要以下步骤:
- 弹出文件目录选择 dialog,使用
dialog.showOpenDialog
实现 - 更新本地缓存中的自定义下载目录
- 修改当前 app 的默认下载目录
- 更新下载窗口中的下载目录文案
// 用户点击更改下载目录的回调
changeDoiwnloadHandler = () => {
const paths = dialog.showOpenDialog({
title: '选择文件存放目录',
properties: ['openDirectory'],
});
if (paths && paths.length) {
// 先更新一下本地缓存
store.set('downloadsPath', paths[0]);
// 更新当前的下载目录
app.setPath('downloads', paths[0]);
// 更新下载目录文案
this.updateDownloadsPath();}
}
计算下载进度
拿到 downloadItem 之后,可以获取到已下载的字节数和文件的总字节数,以此来计算下载进度。
const percent = item.getReceivedBytes() / item.getTotalBytes();
操作文件
在下载管理窗口中,双击下载任务可以打开该文件,点击查看按钮可以打开文件所在目录。我们统一使用 Electron 的 shell 模块来实现。
openFile = (path) => {if (!fs.pathExistsSync) return; // 文件不存在的情况
shell.openItem(path); // 打开文件
}
openFileFolder = async (path) => {if (!fs.pathExistsSync(path)) { // 文件不存在
return;
}
shell.showItemInFolder(path); // 打开文件所在文件夹
}
获取文件关联图标
仔细观察下载管理窗口我们可以发现,文件的图标都是从系统获取的,和我们在文件管理器中看到的文件图标一致。
上图中 dmg、jpg 文件都展示了系统关联的文件图标,用户体验很好。我们可以使用 getFileIcon 来获取系统图标,以下是具体实现代码。
const {app} = require('electron').remote;
// 封装一个函数
const getFileIcon = (path) => {return new Promise((resolve) => {
const defaultIcon = 'some-default.jpg';
if (!path) return resolve(defaultIcon);
return app.getFileIcon(path, (err, nativeImage) => {if (err) {return resolve(defaultIcon);
}
return resolve(nativeImage.toDataURL()); // 使用 base64 展示图标
});
});
};
// 获取图标
const imgSrc = await getFileIcon('./test.jpg');
最后
欢迎大家在评论区讨论,技术交流 & 内推 -> zhongli@qunhemail.com