58

【Electron】酷家乐客户端开发实践分享 — 下载管理器

 5 years ago
source link: https://www.tuicool.com/articles/qYfuAjJ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

背景

打开酷家乐客户端,可以在左下角的更多菜单中找到下载管理这个功能,今天我们就来看看在Electron中如何实现一个下载管理器。

MBb6Zbu.png!web

如何触发下载行为

由于Electron渲染层是基于chromium的,触发下载的逻辑和chromium是一致的,页面中的a标签或者js跳转等等行为都可能触发下载,具体视访问的资源而定。什么样的资源会触发浏览器的下载行为呢?

  1. response header中的 Content-Disposition 为attachment。参考 MDN Content-Disposition
  2. response header中的 Content-Type ,是浏览器无法直接打开的文件类型,例如 application/octet-stream ,此时取决于浏览器的具体实现了。例子: IE无法打开pdf文件,chrome可以直接打开pdf文件,因此pdf类型的url在chrome上可以直接打开,而在IE下会触发下载行为。

在Electron中还有一种方法可以触发下载: webContents.download 。相当于直接调用chromium底层的下载逻辑,忽略headers中的那些判断,直接下载。

上述两种下载行为,都会触发session的 will-download 事件, 在这里可以获取到关键的 downloadItem 对象

整体流程

r6bMrqa.jpg!web

设置文件路径

如果不做任何处理的话,触发下载行为时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.showOpenDialog
// 用户点击更改下载目录的回调
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); // 打开文件所在文件夹
}

获取文件关联图标

仔细观察下载管理窗口我们可以发现,文件的图标都是从系统获取的,和我们在文件管理器中看到的文件图标一致。

RVfEf2M.png!web

上图中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');

最后

欢迎大家在评论区讨论,技术交流 & 内推 -> [email protected]


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK