桌面端|浅谈 Electron 内存泄漏

大概是一段时间内最后一篇关于桌面端 Electron 的文章了,毕竟工作方向又变了。原本想放一篇关于桌面端选型的技术方案调研,但里面是对一些应用的反向解析的过程,自己调研没啥问题,放出来总归感觉不是很好。那本篇就讲讲做了半年 Electron,对它感触最深的一件事。

起因

Electron 是面向前端友好地桌面端框架,看到很多有关注我桌面端文章的同学也都是前端开发。这也是 Electron 优势,看似只要会 TS / JS 语法就能很好的进行桌面端开发了,渲染层也是 chromium 内核,基本不需要做浏览器兼容性处理,可谓十分的友好。

但终究它是桌面端,和移动端一样,本质上都是独立的应用。这和在浏览器上展示是不一样的,我们需要考虑更多,比如整个应用的生命周期,以及… 它为啥活不到整个生命周期…

对于 Electron 来说,想写出个崩溃也是挺难的,如果写的不合理,大部分在启动的时候就直接弹窗报错了,比如 preload.js 找不到等等。那它为什么就活不下去?其实大部分都是一直吃又不消化,内存膨胀,撑死掉的。这一点跟在浏览器开发是不同的,虽然都有 GC(垃圾回收),但用户习惯上,基本不会保持在同一个页面,就算一直保持,也是浏览器帮你在兜底。但我们自己做应用,就要自己来兜兜底,该释放的马上释放,释放不掉的找办法强制释放。

浅谈

写的时候一直想,这篇文章能给看过的你带来什么价值?内存管理,翻来覆去的也要从堆栈开始讲,但懂得都懂,不懂的再讲原理也不差我这一篇。所以本文简单聊聊内存、生命周期。

持有须慎重

new一个对象司空见惯了,为了使用方便,我们是是会把对象去挂载在单例上,比如笔者前面的多容器管理,就是用单例的GDContainerManager来管理各个容器的生命周期。

但持有了就要去关心这个对象什么时候销毁,因为持有后不主动释放,就不算垃圾,垃圾回收(GC)是不生效的。而往往出现问题的原因就是持有这个对象的人有多个,出现了遗漏释放或者循环引用的现象,导致没释放,这时候的风险,占内存倒是其次,往往还会发生意外,比如全局事件广播对这个泄漏对象还在生效就十分的危险,出现调用混乱。

所以 Rust 这门语言就是从编译时解决了 GC 这个痛点问题,所有权和借用从根本上不允许共同持有同一对象(内存)的现象。

解决方式也有很多,但前端开发同学应该也很少有去使用:

  • 创建弱引用对象,熟悉 iOS 的同学应该对WeakObject印象深刻,毕竟无时无刻都要考虑。但其实前端也有一样的定义,这可能很多前端开发都没有用过 WeakRef,用法也很简单:
// 创建一个弱引用的 JS 对象
const myObjRef = new WeakRef(myObj); 
...
// 访问这个弱引用对象
const myObjFromRef = myObjRef.deref(); 

需要注意的是由于弱引用的对象可以随时被垃圾回收(GC),因此不能保证在任何时候都能访问弱引用引用的对象,这导致的后果就是可能得到undefined。这其实就符合我们的初衷,只要一处进行强引用持有,其他都弱引用来持有,只要释放一处就会被回收掉。

  • 对于字典或列表来说,我们可以使用WeakMapWeakMap是ECMAScript 6引入的一种映射集合,其所引用的对象可以随时被垃圾回收机制清理。利用WeakMap可以避免或最小化循环引用导致的内存泄漏。

  • 使用各种深拷贝的方式创建多个独立内存的对象,防止出现循环引用的问题。比如经常见到会使用JSON.parse(JSON.stringily(obj))或者使用lodash提供的便捷方法等。用前要想一下是否符合场景,不能用来一把梭,啥都一把梭会害了你。

  • 手动管理各处 List 或 Map 中持有的对象,在释放时,进行手动delete清理。这需要对代码十分了解,在对象中定义每个成员并在该成员本身中引用该对象,以及在闭包中引用该对象,对外提供清理方法。说白了就是,变量在类内自行管理,尽量不暴露给外部使用,如果需要暴露,则同步暴露清理方法。

    private listenGNBEvents() {
        const handler = (data: any) => {
            ...
        };
        GNBEventBus.shared.subscribe(handler);
        // 事件监听释放闭环,杜绝循环引用
        this.context.webContents.on('destroyed', () => {
            GNBEventBus.shared.unsubscribe(handler);
        });
    }

容器内部的事件监听,需要自行在容器销毁后在事件总线zhong释放掉。

桌面端|浅谈 Electron 内存泄漏

public closeTab(id: number): void {
    ...
    // 关闭标签页的同时需要移除容器管理中引用
    GDContainerManager.shared.removeContainer(id);
}

在容器管理中,对外提供移除容器的方法,让外部在销毁时也要销毁容器管理中的容器引用。

释放需即时

我们做到对象持有合理,不产生循环引用,这样其实还不够。内存垃圾回收(GC)其实并不是实时的,特别是在 Electron 上表现的十分明显。可以简单的观测进程就发现,当释放一个窗口或者容器时,进程并不会马上消失,通常是在2~3秒或者更久后才消失。但这是存在风险的,比如在这释放的窗口期,突然又有引用产生,那会导致这一部分内存直接泄漏了,重则出现僵尸进程,有时候应用销毁后这个进程都还在…

那笔者为了彻底杜绝这个问题,对BrowserWindow/BrowserView的释放都是十分即时的,BrowserView从移除容器即调用销毁webContents.destroy()

this.window.context?.on('close', () => { 
    // 一些窗口的关闭,直接销毁,一点都不等
    this.window.context?.destroy();
});

但这也会产生一些问题,比如笔者常常遇到Object has been destroyed对象已经被销毁,我们虽然及时释放了引用,但对于各个回调监听来说,这个对象其实还有在,但执行到的时候却被销毁了。

所以需要对出现问题的方法中增加一些兼容性:

    /**
     * 执行 JS 方法
     */
    public executeJavaScript(script: string) {
        if (this.context?.webContents?.isDestroyed()) {
            // 先判断是否已被销毁
            return;
        }
        return this.context?.webContents?.executeJavaScript(script).catch((error) => {
            GDLog.containerExecuteJsError(script, (error as Error).message);
        });
    }

进程 or 线程

官网是推荐大家使用多进程的方式来处理复杂逻辑,减轻主进程的负担,官方传送门。但这是有前提的:

桌面端|浅谈 Electron 内存泄漏

确认你的复杂逻辑需要占用大量内存,如果不是,那其实单一主进程足够了,我们可以通过Worker 来创建多线程的方式处理。

啥都多进程,也是会害了你。除了整个应用的内存会变大,一个 node 空进程也是有20MB的内存占用的,还会导致一些多进程的独有的问题。比如要想好多个进程间是否会操作同一个文件,同一个数据库等等。如果有就要处理进程资源竞争,对死锁,争抢,抢占失败的处理,这会导致项目变得复杂的多,当然,如果你是在防御性编程,那护城河确实高 – -|。

后续

可以聊的其实还挺多,但笔者项目中的因内存溢出而导致的崩溃都还没有分析出个123来,看起来也不会再进行分析了,那笔者就不再献丑了,本文到此结束。

最后感叹下,从最开始极度质疑 Electorn,中间不得不接受 Electron,到现在理解 Electron。框架其实没有好与坏,好与坏在于使用它的人。


感谢阅读,如果对你有用请点个赞 ❤️

桌面端|浅谈 Electron 内存泄漏

原文链接:https://juejin.cn/post/7326567743989121051 作者:园宵

(0)
上一篇 2024年1月22日 下午4:21
下一篇 2024年1月22日 下午4:31

相关推荐

发表回复

登录后才能评论