Obeing

Obeing 查看完整档案

广州编辑广东工业大学  |  信息管理与信息系统 编辑xxxx  |  前端开发工程师 编辑 www.bingou.site 编辑
编辑

努力地成为一只小牛

个人动态

Obeing 赞了文章 · 7月24日

细说websocket快速重连机制

网易智慧企业web前端开发工程师 马莹莹

引言

在一个完善的即时通讯应用中,websocket是极其关键的一环,它为web应用的客户端和服务端提供了一种全双工的通信机制,但由于它本身以及其底层依赖的TCP连接的不稳定性,开发者不得不为其设计一套完整的保活、验活、重连方案,才能在实际应用中保证应用的即时性和高可用性。就重连而言,其速度严重影响了上层应用的“即时性”和用户体验,试想打开网络一分钟后,微信还不能收发消息的话,是不是要抓狂?

因此,如何在网络变更时快速恢复websocket的可用,就变得尤为重要。

快速了解websocet

Websocket诞生于2008年,在2011年成为国际标准,现在所有的浏览器都已支持。它是一种全新的应用层协议,是专门为web客户端和服务端设计的真正的全双工通信协议,

可以类比HTTP协议来了解websocket协议。它们的不同点:

  • HTTP的协议标识符是http,websocket的是ws
  • HTTP请求只能由客户端发起,服务器无法主动向客户端推送消息,而websocket可以
  • HTTP请求有同源限制,不同源之间通信需要跨域,而websocket没有同源限制

相同点:

  • 都是应用层的通信协议
  • 默认端口一样,都是80或443
  • 都可以用于浏览器和服务器间的通信
  • 都基于TCP协议

两者和TCP的关系图:
1.jpg

图片来源

重连过程拆解

首先考虑一个问题,何时需要重连?

最容易想到的是websocket连接断了,为了接下来能收发消息,我们需要再发起一次连接。但在很多场景下,即便websocket连接没有断开,实际上也不可用了,比如设备切换网络、链路中间路由崩溃、服务器负载持续过高无法响应等,这些场景下的websocket都没有断开,但对上层来说,都没办法正常的收发数据了。因此在重连前,我们需要一种机制来感知连接是否可用、服务是否可用,而且要能快速感知,以便能够快速从不可用状态中恢复。

一旦感知到了连接不可用,那便可以弃旧图新了,弃用并断开旧连接,然后发起一次新连接。这两个步骤看似简单,但若想达到快,且不是那么容易的。

首先是断开旧连接,对客户端来说,如何快速快速断开?协议规定客户端必须要和服务器协商后才能断开websocket连接,但是当客户端已经联系不上服务器、无法协商时,如何断开并快速恢复?

其次是快速发起新连接。此快非彼快,这里的快并非是立即发起连接,立即发起连接会对服务器带来不可预估的影响。重连时通常会采用一些退避算法,延迟一段时间后再发起重连。但如何在重连间隔和性能消耗间做出权衡?如何在“恰当的时间点”快速发起连接?

带着这些疑问,我们来细看下这三个过程。

2.png

快速感知何时需要重连

需要重连的场景可以细分为三种,一是连接断开了,二是连接没断但是不可用,三是连接对端的服务不可用了。

第一种场景很简单,连接直接断开了,肯定需要重连了。

而对于后两者,无论是连接不可用,还是服务不可用,对上层应用的影响都是不能再收发即时消息了,所以从这个角度出发,感知何时需要重连的一种简单粗暴的方法就是通过心跳包超时:发送一个心跳包,如果超过特定的时间后还没有收到服务器回包,则认为服务不可用,如下图中左侧的方案;这种方法最直接。那如果想要快速感知呢,就只能多发心跳包,加快心跳频率。但是心跳太快对移动端流量、电量的消耗又会太多,所以使用这种方法没办法做到快速感知,可以作为检测连接和服务可用的兜底机制。

3.png

如果要检测连接不可用,除了用心跳检测,还可以通过判断网络状态来实现,因为断网、切换wifi、切换网络是导致连接不可用的最直接原因,所以在网络状态由offline变为online时,大多数情况下需要重连下,但也不一定,因为webscoket底层是基于TCP的,TCP连接不能敏锐的感知到应用层的网络变化,所以有时候即便网络断开了一小会,对websocket连接是不会有影响的,网络恢复后,仍然能够正常地进行通信。因此在网络由断开到连接上时,立即判断下连接是否可用,可以通过发一个心跳包判断,如果能够正常收到服务器的心跳回包,则说明连接仍是可用的,如果等待超时后仍没有收到心跳回包,则需要重连,如上图中的右侧。这种方法的优点是速度快,在网络恢复后能够第一时间感知连接是否可用,不可用的话可以快速执行恢复,但它只能覆盖应用层网络变化导致websocket不可用的情况。

综上,定时发送心跳包检测的方案贵在稳定,能够覆盖所有场景,但速度不太可;而判断网络状态的方案速度快,无需等待心跳间隔,较为灵敏,但覆盖场景较为局限。因此,我们可以结合两种方案:定时以不太快的频率发送心跳包,比如40s/次、60s/次等,具体可以根据应用场景来定,然后在网络状态由offline变为online时立即发送一次心跳,检测当前连接是否可用,不可用的话立即进行恢复处理。这样在大多数情况下,上层的应用通信都能较快从不可用状态中恢复,对于少部分场景,有定时心跳作为兜底,在一个心跳周期内也能够恢复。

快速断开旧连接

通常情况下,在发起下一次连接前,如果旧连接还存在的话,应该先把旧连接断开,这样一来可以释放客户端和服务器的资源,二来可以避免之后误从旧连接收发数据。

我们知道websocket底层是基于TCP协议传输数据的,连接两端分别是服务器和客户端,而TCP的TIME_WAIT状态是由服务器端维持的,因此在大多数正常情况下,应该由服务器发起断开底层TCP连接,而不是客户端。也就是说,要断开websocket连接时,如果是服务器收到指示要断开websocket,那它应该立即发起断开TCP连接;如果是客户端收到指示要断开websocket,那它应该发信号给服务器,然后等待底层TCP连接被服务器断开或直至超时。

那如果客户端想要断开旧的websocket,可以分websocket连接可用和不可用两种情况来讨论。当旧连接可用时,客户端可以直接给服务器发送断开信号,然后服务器发起断开连接即可;当旧连接不可用时,比如客户端切换了wifi,客户端发送了断开信号,但是服务器收不到,客户端只能迟迟等待,直至超时才能被允许断开。超时断开的过程相对来说是比较久的,那有没有办法可以快点断开?

上层应用无法改变只能由服务器发起断开连接这种协议层面的规则,所以只能从应用逻辑入手,比如在上层通过业务逻辑保证旧连接完全失效,模拟连接断开,然后在发起新连接,恢复通讯。这种方法相当于尝试断开旧连接不行时,直接弃之,然后就能快速进入下一流程,所以在使用时一定要确保在业务逻辑上旧连接已完全失效,比如:保证丢掉从旧连接收到所有数据、旧连接不能阻碍新连接的建立,旧连接超时断开后不能影响新连接和上层业务逻辑等等。

快速发起新连接

有IM开发经验的同学应该有所了解,遇到因网络原因导致的重连时,是万万不能立即发起一次新连接的,否则当出现网络抖动时,所有的设备都会立即同时向服务器发起连接,这无异于黑客通过发起大量请求消耗网络带宽引起的拒绝服务攻击,这对服务器来说简直是灾难。所以在重连时通常采用一些退避算法,延迟一段时间再发起重连,如下图中左侧的流程。

4.png

如果要快速连上呢?最直接的做法就是缩短重试间隔,重试间隔越短,在网络恢复后就能越快的恢复通讯。但是太频繁的重试对性能、带宽、电量的消耗就比较严重。如何在这之间做一个较好的权衡呢?

一种比较合理的方式是随着重试次数增多,逐渐增大重试间隔;另一方面监听网络变化,在网络状态由offline变为online这种比较可能重连上的时刻,可以适当地减小重连间隔,如上图中的右侧(随重试次数的增多,重连间隔也会变大),两种方式配合使用。

除此之外,还可以结合业务逻辑,根据成功重连上的可能性适当的调整间隔,如网络未连接时或应用在后台时重连间隔可以调大一些,网络正常的状态下可以适当调小一些等等,加快重连上的速度。

结尾

最后总结一下,本文在开头将websocket断网重连细分为三个步骤:确定何时需要重连、断开旧连接和发起新连接。然后分别分析了在websocket的不同状态下、不同的网络状态下,如何快速完成这个三个步骤:首先通过定时发送心跳包的方式检测当前连接是否可用,同时监测网络恢复事件,在恢复后立即发送一次心跳,快速感知当前状态,判断是否需要重连;其次正常情况下由服务器断开旧连接,与服务器失去联系时直接弃用旧连接,上层模拟断开,来实现快速断开;最后发起新连接时使用退避算法延迟一段时间再发起连接,同时考虑到资源浪费和重连速度,可以在网络离线时调大重连间隔,在网络正常或网络由offline变为online时缩小重连间隔,使之尽可能快地重连上。

参考:

了解网易云信,来自网易核心架构的通信与视频云服务>>

更多技术干货,欢迎关注vx公众号“网易智慧企业技术+”。系列课程提前看,精品礼物免费得,还可直接对话CTO。

听网易CTO讲述前沿观察,看最有价值技术干货,学网易最新实践经验。网易智慧企业技术+,陪你从思考者成长为技术专家。

查看原文

赞 3 收藏 2 评论 0

Obeing 收藏了文章 · 5月8日

service worker轻度探索 - 解决运营活动需求中的图片加载问题?

写在前面

本文首发于公众号:符合预期的CoyPan

做过运营活动需求的同学都知道,一般一个运营活动中会用到很多的图片资源。用户访问首页时,都会看到一个loading态,表示页面正在加载所需的所有图片资源。像下面这样:

图片描述

手动加载一个图片的代码也很简单:

var img = new Image();
img.onload = function(){ ... }
img.src = '图片地址';

之所以要提前加载所有的图片,是为了在后续的页面中使用图片时,不会因为需要加载图片而产生耗时,导致体验问题。本文所要讨论的场景就是:怎么样做到在首页加载图片后,直接在后面的业务逻辑中直接使用提前加载好的图片呢?答案就是:把图片存下来。

缓存首页加载的图片

我能想到的这种场景下的缓存图片方法有两种:

  1. 使用浏览器的缓存。图片在第一次请求成功后,一般都会设置缓存。在页面后续的业务逻辑中,如果说想使用某图片,直接正常发起图片请求即可,浏览器会走缓存,甚至是从内存中直接返回这个图片。
  2. 将加载好的Image对象直接保存在内存中。这种方法很适用canvas中画图的场景,直接把保存下来的Image对象扔到canvas的drawImage中即可。

做业务需要不断的总结,思考。还能用什么方法来实现图片的缓存呢 ? 我尝试了一下Service Worker,本文将介绍一下Service Worker在这种业务场景下的应用。

本文只是轻轻尝试了一下Service Worker,并未在线上项目中应用。

Service Worker

Service Worker是PWA的重要组成部分,其包含安装、激活、等待、销毁等四个生命周期。主要有以下的特性:

  • 一个独立的 worker 线程,独立于当前网页进程,有自己独立的 worker context。
  • 一旦被 install,就永远存在,除非被手动 unregister
  • 用到的时候可以直接唤醒,不用的时候自动睡眠
  • 可编程拦截代理请求和返回,缓存文件,缓存的文件可以被网页进程取到(包括网络离线状态)
  • 离线内容开发者可控
  • 能向客户端推送消息
  • 不能直接操作 DOM
  • 必须在 HTTPS 环境下才能工作( 或 http://localhost )
  • 异步实现,内部大都是通过 Promise 实现

在本文所描述的业务场景中,主要是应用service worker的拦截代理请求和返回的功能。

关于service worker的基础,谷歌开发者网站上有详细的介绍,这里就不赘述了。

地址在这里:https://developers.google.com...

需要注意的是,service worker一定要谨慎使用,因为它太重要了,一旦注册,站点的所有请求都会被控制。

Service Worker的示例

结合文章开头所描述的场景,我们先来写一些必要的业务函数。

// 加载一个图片
function loadImage(imgUrl) {
    return new Promise((resolve, reject)=>{
        const img = new Image();
        img.onload = function() {
            resolve();
        };
        img.src = imgUrl;
    });
}

// 加载一堆图片
function loadImageList(imgList) {
    return Promise.all(imgList.map(function (imgUrl) {
        return loadImage(imgUrl);
    }));
}

下面是service worker的代码:

self.addEventListener('install', function (event) {
    console.log('install');
});

self.addEventListener('fetch', function (evt) {
    evt.respondWith(
        caches.match(evt.request).then(function(response) {
            if (response) {
                return response;
            }
            const request = evt.request.clone();
            return fetch(request).then(function (response) {
                if (!response || response.status !== 200 || !response.headers.get('Content-type').match(/image/)) {
                    return response;
                }
                const responseClone = response.clone(); // 流数据需要克隆一份。注意事项②
                caches.open('test-cache').then(function (cache) { 
                    cache.put(evt.request, responseClone);
                });
                return response;
            });
        })
    )
});

self.addEventListener('activate', function () {
    console.log('activate');
    clients.claim(); // 首次activate后,就控制页面。注意事项①
});

注册完service worker后,我们就劫持了页面的所有请求。每一次请求经过service worker时,都会判断刚请求是否已有缓存,如果有缓存,就直接返回结果。没有缓存时,才会向服务器发起请求,并且将图片请求的结果缓存起来。

在业务代码中,我们注册并使用这个service worker的代码如下:

// 需要加载的图片列表
const imgArr = ['http://xxx.jpg', '...'];

// 注册service worker
function registerServiceWorker() {
    if ('serviceWorker' in navigator) {
        return navigator.serviceWorker.register('http://localhost:8080/service.js');
    } else {
        // 没有service的处理逻辑省略
    }
}

registerServiceWorker().then(registration => { // 注意事项③
    let serviceWorker;
    if (registration.installing) {
        console.log('registration.installing');
        serviceWorker = registration.installing;
    } else if (registration.waiting) {
        console.log('registration.waiting');
        serviceWorker = registration.waiting;
    } else if (registration.active) {
        console.log('registration.active');
        serviceWorker = registration.active;
        loadImageList(imgArr);
    }
    if (serviceWorker) {
        serviceWorker.addEventListener('statechange', function (e) {
            if(e.target.state === 'activated') {
                // 首次注册时
                console.log('首次注册sw时,sw激活');
                loadImageList(imgArr);
            }
        });
    }
}).catch(e => {
    console.log(e);
});

注意事项:

  1. 正常情况下,service worker刚注册时,是不会控制页面的,即无法拦截到页面的请求。需要用户刷新页面,再次访问时,service worker才会拦截页面请求。这与我们的需求场景不符合。我们的需求是:用户首次访问请求图片资源时,就需要对返回的图片进行缓存。所以,需要在service worker进入activate状态后,通过clients.claim()来获得页面的控制权。不过,这种方式并不被提倡
  2. service worker拦截到请求后,我们需要拷贝返回的数据流,才能存入缓存。
  3. 在业务代码中,我们每次都需要调用navigator.serviceWorker.register来拿到一个service worker。浏览器会判断当前service worker的状态,返回对应的对象。我们需要保证在service worker准备无误后,再发起图片的请求。由于server worker的自身逻辑需要一定的时间,所以我们发起图片请求的时间会被延后。

使用service worker后的效果

以我做的运营活动项目为例,使用service worker之前,网络请求是这样的:

  • 活动页首页,首次集中请求图片

图片描述

  • 活动页后续页面中,使用加载好的图片:

    图片描述

使用service-worker之后,网络请求是这样的:

  • 活动页首页,首次集中请求图片:

    图片描述

  • 活动页后续页面中,使用加载好的图片:

    图片描述

可以看到,我们成功使用service worker劫持了页面的请求,并且将图片缓存到了浏览器的cache storage中。我们来看一下浏览器的缓存。这里的缓存都是http response。

图片描述

另外这里多说一句,可以使用下面的代码,来查看当前网站可以使用的浏览器本地存储空间

if ('storage' in navigator && 'estimate' in navigator.storage) {
  navigator.storage.estimate().then(({usage, quota}) => {
    console.log(`Using ${usage} out of ${quota} bytes.`);
  });
}

一些思考

在本文提到的场景中,我们在用户首次访问页面时,先注册了service worker,并且使service worker立即控制页面,然后再开始请求图片。这种做法延后了图片请求的发起时间,并且从上面的图中可以看到,通过service worker加载图片的耗时比正常直接请求图片耗时略长。这些因素导致首屏时间被延后了。另外,作为运营活动页,同一个用户也不会在几天内多次访问,因此service worker的【绕过网络,立即响应请求】的特性并不能很好地发挥出来。因此,在本文描述的场景中,使用service worker来做缓存并不是最佳实践。

关于service worker做缓存的最佳实践以及使用场景,可以查看这篇文章:

https://developers.google.com...

service worker最适合的场景还是资源离线化,用户二次进入页面时可以达到资源秒加载,不会受网络状况的影响。

写在后面

本文从业务的角度出发,轻度探索了service worker在文章开头给出的业务场景中的应用。后续会考虑在合适的业务场景中进行应用。


图片描述

查看原文

Obeing 赞了文章 · 5月8日

service worker轻度探索 - 解决运营活动需求中的图片加载问题?

写在前面

本文首发于公众号:符合预期的CoyPan

做过运营活动需求的同学都知道,一般一个运营活动中会用到很多的图片资源。用户访问首页时,都会看到一个loading态,表示页面正在加载所需的所有图片资源。像下面这样:

图片描述

手动加载一个图片的代码也很简单:

var img = new Image();
img.onload = function(){ ... }
img.src = '图片地址';

之所以要提前加载所有的图片,是为了在后续的页面中使用图片时,不会因为需要加载图片而产生耗时,导致体验问题。本文所要讨论的场景就是:怎么样做到在首页加载图片后,直接在后面的业务逻辑中直接使用提前加载好的图片呢?答案就是:把图片存下来。

缓存首页加载的图片

我能想到的这种场景下的缓存图片方法有两种:

  1. 使用浏览器的缓存。图片在第一次请求成功后,一般都会设置缓存。在页面后续的业务逻辑中,如果说想使用某图片,直接正常发起图片请求即可,浏览器会走缓存,甚至是从内存中直接返回这个图片。
  2. 将加载好的Image对象直接保存在内存中。这种方法很适用canvas中画图的场景,直接把保存下来的Image对象扔到canvas的drawImage中即可。

做业务需要不断的总结,思考。还能用什么方法来实现图片的缓存呢 ? 我尝试了一下Service Worker,本文将介绍一下Service Worker在这种业务场景下的应用。

本文只是轻轻尝试了一下Service Worker,并未在线上项目中应用。

Service Worker

Service Worker是PWA的重要组成部分,其包含安装、激活、等待、销毁等四个生命周期。主要有以下的特性:

  • 一个独立的 worker 线程,独立于当前网页进程,有自己独立的 worker context。
  • 一旦被 install,就永远存在,除非被手动 unregister
  • 用到的时候可以直接唤醒,不用的时候自动睡眠
  • 可编程拦截代理请求和返回,缓存文件,缓存的文件可以被网页进程取到(包括网络离线状态)
  • 离线内容开发者可控
  • 能向客户端推送消息
  • 不能直接操作 DOM
  • 必须在 HTTPS 环境下才能工作( 或 http://localhost )
  • 异步实现,内部大都是通过 Promise 实现

在本文所描述的业务场景中,主要是应用service worker的拦截代理请求和返回的功能。

关于service worker的基础,谷歌开发者网站上有详细的介绍,这里就不赘述了。

地址在这里:https://developers.google.com...

需要注意的是,service worker一定要谨慎使用,因为它太重要了,一旦注册,站点的所有请求都会被控制。

Service Worker的示例

结合文章开头所描述的场景,我们先来写一些必要的业务函数。

// 加载一个图片
function loadImage(imgUrl) {
    return new Promise((resolve, reject)=>{
        const img = new Image();
        img.onload = function() {
            resolve();
        };
        img.src = imgUrl;
    });
}

// 加载一堆图片
function loadImageList(imgList) {
    return Promise.all(imgList.map(function (imgUrl) {
        return loadImage(imgUrl);
    }));
}

下面是service worker的代码:

self.addEventListener('install', function (event) {
    console.log('install');
});

self.addEventListener('fetch', function (evt) {
    evt.respondWith(
        caches.match(evt.request).then(function(response) {
            if (response) {
                return response;
            }
            const request = evt.request.clone();
            return fetch(request).then(function (response) {
                if (!response || response.status !== 200 || !response.headers.get('Content-type').match(/image/)) {
                    return response;
                }
                const responseClone = response.clone(); // 流数据需要克隆一份。注意事项②
                caches.open('test-cache').then(function (cache) { 
                    cache.put(evt.request, responseClone);
                });
                return response;
            });
        })
    )
});

self.addEventListener('activate', function () {
    console.log('activate');
    clients.claim(); // 首次activate后,就控制页面。注意事项①
});

注册完service worker后,我们就劫持了页面的所有请求。每一次请求经过service worker时,都会判断刚请求是否已有缓存,如果有缓存,就直接返回结果。没有缓存时,才会向服务器发起请求,并且将图片请求的结果缓存起来。

在业务代码中,我们注册并使用这个service worker的代码如下:

// 需要加载的图片列表
const imgArr = ['http://xxx.jpg', '...'];

// 注册service worker
function registerServiceWorker() {
    if ('serviceWorker' in navigator) {
        return navigator.serviceWorker.register('http://localhost:8080/service.js');
    } else {
        // 没有service的处理逻辑省略
    }
}

registerServiceWorker().then(registration => { // 注意事项③
    let serviceWorker;
    if (registration.installing) {
        console.log('registration.installing');
        serviceWorker = registration.installing;
    } else if (registration.waiting) {
        console.log('registration.waiting');
        serviceWorker = registration.waiting;
    } else if (registration.active) {
        console.log('registration.active');
        serviceWorker = registration.active;
        loadImageList(imgArr);
    }
    if (serviceWorker) {
        serviceWorker.addEventListener('statechange', function (e) {
            if(e.target.state === 'activated') {
                // 首次注册时
                console.log('首次注册sw时,sw激活');
                loadImageList(imgArr);
            }
        });
    }
}).catch(e => {
    console.log(e);
});

注意事项:

  1. 正常情况下,service worker刚注册时,是不会控制页面的,即无法拦截到页面的请求。需要用户刷新页面,再次访问时,service worker才会拦截页面请求。这与我们的需求场景不符合。我们的需求是:用户首次访问请求图片资源时,就需要对返回的图片进行缓存。所以,需要在service worker进入activate状态后,通过clients.claim()来获得页面的控制权。不过,这种方式并不被提倡
  2. service worker拦截到请求后,我们需要拷贝返回的数据流,才能存入缓存。
  3. 在业务代码中,我们每次都需要调用navigator.serviceWorker.register来拿到一个service worker。浏览器会判断当前service worker的状态,返回对应的对象。我们需要保证在service worker准备无误后,再发起图片的请求。由于server worker的自身逻辑需要一定的时间,所以我们发起图片请求的时间会被延后。

使用service worker后的效果

以我做的运营活动项目为例,使用service worker之前,网络请求是这样的:

  • 活动页首页,首次集中请求图片

图片描述

  • 活动页后续页面中,使用加载好的图片:

    图片描述

使用service-worker之后,网络请求是这样的:

  • 活动页首页,首次集中请求图片:

    图片描述

  • 活动页后续页面中,使用加载好的图片:

    图片描述

可以看到,我们成功使用service worker劫持了页面的请求,并且将图片缓存到了浏览器的cache storage中。我们来看一下浏览器的缓存。这里的缓存都是http response。

图片描述

另外这里多说一句,可以使用下面的代码,来查看当前网站可以使用的浏览器本地存储空间

if ('storage' in navigator && 'estimate' in navigator.storage) {
  navigator.storage.estimate().then(({usage, quota}) => {
    console.log(`Using ${usage} out of ${quota} bytes.`);
  });
}

一些思考

在本文提到的场景中,我们在用户首次访问页面时,先注册了service worker,并且使service worker立即控制页面,然后再开始请求图片。这种做法延后了图片请求的发起时间,并且从上面的图中可以看到,通过service worker加载图片的耗时比正常直接请求图片耗时略长。这些因素导致首屏时间被延后了。另外,作为运营活动页,同一个用户也不会在几天内多次访问,因此service worker的【绕过网络,立即响应请求】的特性并不能很好地发挥出来。因此,在本文描述的场景中,使用service worker来做缓存并不是最佳实践。

关于service worker做缓存的最佳实践以及使用场景,可以查看这篇文章:

https://developers.google.com...

service worker最适合的场景还是资源离线化,用户二次进入页面时可以达到资源秒加载,不会受网络状况的影响。

写在后面

本文从业务的角度出发,轻度探索了service worker在文章开头给出的业务场景中的应用。后续会考虑在合适的业务场景中进行应用。


图片描述

查看原文

赞 18 收藏 17 评论 6

Obeing 发布了文章 · 4月30日

Web Audio Api与HTML Audio

最近在 web 手机页面和 webview 上遇到了不少关于声音的问题,深入研究下egret用的两种播放声音的方式

HTML Audio

简单使用:

const audioObj = new Audio('./resource/assets/sounds/home_bg.mp3');
            audioObj.addEventListener('canplaythrough', (event) => {
                console.log('loaded');
                // audioObj.play();
            });

Audio()构造器创建并返回一个 HTMLAudioElement,通过标签的形式加载声音,创建的这个标签可以不用append到html中播放。

HTML Audio 在 canplaythrough 事件中声音资源已经被加载成功如果直接调用play方法,会报错并且不播web标准中认为只有在用户主动操作页面之后才允许播放,否则会惊吓到用户。所以上面的代码要改成这样才可以正常播放。

const audioObj = new Audio('./resource/assets/sounds/home_bg.mp3');
            let loaded = false;
            audioObj.addEventListener('canplaythrough', (event) => {
                loaded = true;
                console.log('loaded');
                // 不添加点击事件直接播放报错
                // test.html:13 Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first. https://goo.gl/xX8pDD
                // /* the audio is now playable; play it if permissions allow */
                // audioObj.play();
            });
            window.onload = () => {
                document.body.addEventListener('click', () => {
                    if (loaded) {
                        audioObj.play();
                    }
                });
            };

Web Audio Api

web audio api的使用和理解稍微复杂一点

一个简单而典型的web audio流程如下:
1. 创建音频上下文
2. 在音频上下文里创建源 — 例如 <audio>, 振荡器, 流
3. 创建效果节点,例如混响、双二阶滤波器、平移、压缩
4. 为音频选择一个目的地,例如你的系统扬声器
5. 连接源到效果器,对目的地进行效果输出
使用这个API,时间可以被非常精确地控制,几乎没有延迟,这样开发人员可以准确地响应事件,并且可以针对采样数据进行编程,甚至是较高的采样率。这样,鼓点和节拍是准确无误的。

1.创建AudioContext

window.AudioContext = window.AudioContext||window.webkitAudioContext;
const context = new AudioContext();

2.通过ajax请求资源,注意将 responseType 设置为 arraybuffer

function loadDogSound(url) {
  var request = new XMLHttpRequest();
  request.open('GET', url, true);
  request.responseType = 'arraybuffer';

  // Decode asynchronously
  request.onload = function() {
    context.decodeAudioData(request.response, function(buffer) {
      dogBarkingBuffer = buffer;
    }, onError);
  }
  request.send();
}

3.播放声音,创建声音源并将它们连接到AudioContext实例提供的声音目标

function playSound(buffer) {
  var source = context.createBufferSource(); // creates a sound source
  source.buffer = buffer;                    // tell the source which sound to play
  source.connect(context.destination);       // connect the source to the context's destination (the speakers)
  source.start(0);                           // play the source now
                                             // note: on older systems, may have to use deprecated noteOn(time);
}

HTML Audio 与 Web Audio Api的不同

Web Audio API并不会取代<audio>音频元素,倒不如说它是<audio>的补充更好,就好比如<canvas>与<img>共存的关系。你使用来实现音频的方式取决于你的使用情况。如果你只是想控制一个简单的音轨的播放,<audio>或许是一个更好更快的选择。如果你想实现更多复杂的音频处理,以及播放,Web Audio API提供了更多的优势以及控制。例如:立体声声像

总结:

  1. HTML Audio通过标签请求,web Audio api通过ajax请求
  2. web Audio api是更高级的用法可以处理更复杂的声音场景。
  3. 在egret中默认是用web Audio api

关于自动播放

通常,您可以假定仅当以下至少一项为真时才允许媒体自动播放:
* 音频被静音或其音量设置为0
* 用户已经与站点进行了交互(通过单击,点击,按下键等)。
* 如果站点已被列入白名单;如果浏览器确定用户经常与媒体互动,这可能会自动发生,也可能通过首选项或其他用户界面功能手动发生
* 如果自动播放功能策略用于向<iframe>和及其文档授予自动播放支持。
否则,播放可能会被阻止。导致阻塞的确切情况以及将网站列入白名单的具体方法因浏览器而异,但以上是遵循的良好指南。
注意:换句话说,如果在尚未与用户进行任何交互的选项卡中以编程方式启动播放,则通常会阻止任何包含音频的媒体的播放。浏览器还可以选择在其他情况下进行阻止。

Web Audio Api 如果直接调用了play方法在实际测试中,安卓手机无论是webview还是web页面只要支持Audio特性的浏览器几乎都可以不经过用户操作直接播放,但是ios会有很多不一样的表现。用户没有操作过直接播放,会导致 ctx.state 状态挂起,如果不手动 resume 即使用户后续操作了也没有办法正常播放。
image.png

总的建议是:
1.尽量不要使用自动播放,否则会有各种各样的问题。
2.如果需要自动播放除了做好兼容以外,也要考虑到用户体验,选择一个声音从小到大渐进的音乐。

兼容性问题

在低版本的安卓机中(如安卓4.2)AudioContext 为 undefined,不可用
Audio 虽然存在,但是资源加载可能会有问题,不会走 canplay,complete 等事件,如果资源加载是一个前置条件(例如egret的资源组加载,就会监听不了complete事件导致一直await),需要尝试在 stalled 等事件中做错误处理。https://developer.mozilla.org...

离线包

上面提到,html audio 通过标签的方式加载,请求类型是 media,在离线包的请求中是可以被拦截缓存的。
但是 web Audio api 通过 ajax 请求类型是 xhr,在 ios 中不能被拦截。
虽然mdn上也有示例 web audio api 也可以通过标签形式加载,但是也用到了html audio。

image.png

参考链接

https://www.html5rocks.com/en...
https://www.zhangxinxu.com/wo...

查看原文

赞 0 收藏 0 评论 0

Obeing 发布了文章 · 2月29日

egret动画与更新

问题


最近发现游戏在 webview 中操作交换事件掉帧特别厉害,有时候直接跳过了交换的动画。猜想是因为逻辑需要计算后续所有的步骤,在计算完成之前这部分逻辑就相当于阻塞动画。因此阅读动画和Ticker帧刷新的源码,证明猜想。

代码逻辑

  1. 监听用户 touchmove 事件,将最近两个元素位置获取
  2. 调用 exchange 方法,同时发送事件 EE.emit(EventType.MAP_EXCHANGE)
  3. 因为 EE 发送事件是同步执行,所以会执行 sTween.to({ x, y}, 200);
  4. 执行 CrushCells 方法,这个方法逻辑比较复杂有可能需要递归计算多次,耗时基本在16ms以上。

Tween动画源码实现

Tween 动画最核心的方法是 Tween.to 方法

public to(props: any, duration?: number, ease: Function = undefined) {
            if (isNaN(duration) || duration < 0) {
                duration = 0;
            }
            this._addStep({ d: duration || 0, p0: this._cloneProps(this._curQueueProps), e: ease, p1: this._cloneProps(this._appendQueueProps(props)) });
            //加入一步set,防止游戏极其卡顿时候,to后面的call取到的属性值不对
            return this.set(props);
        }

每一个tween实例都有一个steps队列执行,将每次调用需要变化的属性值保存在队列中,等待时机取出执行,这个时机先按下不表,需要记住 _addStep 这个方法。

帧动画更新

由于主流的屏幕刷新率都在 60Hz,那么渲染一帧的时间就必须控制在 16ms 才能保证不掉帧。 也就是说每一次渲染都要在 16ms 内页面才够流畅不会有卡顿感

在 egret 中设置 frameRate = 60,控制canvas重绘的ticker就是每16-17ms秒执行一次回调。

ticker.$startTick(Tween.tick, null);
private static tick(timeStamp: number, paused = false): boolean {
            let delta = timeStamp - Tween._lastTime;
            Tween._lastTime = timeStamp;

            let tweens: Tween[] = Tween._tweens.concat();
            for (let i = tweens.length - 1; i >= 0; i--) {
                let tween: Tween = tweens[i];
                if ((paused && !tween.ignoreGlobalPause) || tween.paused) {
                    continue;
                }
                tween.$tick(tween._useTicks ? 1 : delta);
            }

            return false;
        }

在ticker的回调中获取参数 timeStamp (表示从启动Egret框架开始经过的时间ms),在没有阻塞的情况下,delta等于16|17ms,这时候tween.$tick方法再去取steps队列的属性,根据 (prevPosition + delta)/ duration 的比例更新updateTargetProps

如果计算量很大,耗时超过了16-17ms,比如说24ms就只能走下一个tick,如果甚至大到超过duration,那么动画就相当于消失了,变成直接设置属性。而CrushCells方法因为考虑到了后续所有的步骤所以都耗时超过了16毫秒,这就是为什么交换事件掉帧。

解决问题

  • 之前代码重构过一次,以前逻辑与动画耦合,逻辑每次都等待动画播放完才进行下一次的计算应该不会有掉帧的问题,但是这样会难以实现一些跳过动画的需求。
  • 如果后续的计算利用 setTimeout 分割成一片片计算,每次都在异步队列中等待,动画运行完毕之后才继续计算理论上能够解决这个阻塞的问题。
  • 这样有点类似于重构前逻辑必须等待动画执行完后才计算,但是不同的地方在于,如果选择跳过动画却不影响逻辑的计算。

表现:在devtool performace中选择cpu slow down 4的情况下,ticker更新动画在交换事件中回调时间会从80多ms降到20多ms。

查看原文

赞 0 收藏 0 评论 0

Obeing 发布了文章 · 2019-12-29

egret适配模式

项目中,通过devtool模拟器为iphone6/7/8 plus中设备像素为414*736,canvas的宽高为1242 * 2208,根据设计稿配置的内容宽高是750 * 1204,这么多数据到底是怎么计算出来的,如何适配的?
image.png

全局函数 updateAllScreens

这个方法用于刷新所有 Egret 播放器的显示区域尺寸。仅当使用外部 JavaScript 代码动态修改了 Egret 容器大小时,需要手动调用此方法刷新显示区域。当网页尺寸发生改变时(触发 resize 事件)此方法会自动被调用。
这里说的播放器WebPlayer实际上是指有 css 类选择器为 .egret-player的区域,WebPlayer 并不是语义上的播放器意思,而是根据获取的.egret-player dom元素初始化的一个对象。
另外class="egret-player" 这个模版文件的class是webplayer获取的dom的唯一途径要是不小心删了就无法渲染了。

更新播放器视口尺寸

通过container.getBoundingClientRect()分别计算screenWidth、screenHeight,container就是上面所说的webplayer dom元素,通过传以下4个参数调用calculateStageSize方法调整视口。

* 计算舞台显示尺寸
* @param scaleMode 当前的缩放模式
* @param screenWidth 播放器视口宽度 screenRect.width
* @param screenHeight 播放器视口高度 screenRect.height
* @param contentWidth 初始化内容宽度 option.contentWidth
* @param contentHeight 初始化内容高度 option.contentHeight

以设计稿为750 * 1204, iphone6/7/8 plus 414 * 736为例分析各种适配模式下视口尺寸的大小。

// 初始化
let displayWidth = screenWidth; // 414
let displayHeight = screenHeight; // 736
let stageWidth = contentWidth; // 750
let stageHeight = contentHeight; //1204

calculateStageSize方法返回调整后的displayWidth、displayHeight、stageWidth、stageHeight4个参数

FIXED_WIDTH模式

1.计算width缩放比例:scaleX = 视口宽度/内容宽度 = screenWidth/contentWidth = 0.522
2.改变内容高度 :stageHeight = 内容高度= 视口高度/scaleX = 736 / 0.522 = 1333
也就是将内容高度改变从1204变为1333,但由于宽高不是2的整数倍会导致图片绘制出现问题,所以最后调整为1334

根据调整后的视口尺寸和container的宽高计算画布距离container的距离。因为fixed_width模式下视口宽高没有发生变化,所以top、left为0,画布贴合左上角

canvas.style.top = (boundingClientHeight - displayHeight) / 2 + "px";
canvas.style.left = (boundingClientWidth - displayWidth) / 2 + "px";

由于iphone6/7/8plus为dpr为3的高清屏,为了解决1:1像素画布在retina屏下模糊的问题,egret会对画布宽高乘以dpr大小再进行缩放。

let scalex = displayWidth / stageWidth, // 0.552
scaley = displayHeight / stageHeight; // 0.551724138
// 这里的$canvasScaleFactor是window.devicePixelRatio: 3
let canvasScaleX = scalex * sys.DisplayList.$canvasScaleFactor; // 约1.656
let canvasScaleY = scaley * sys.DisplayList.$canvasScaleFactor; // 约1.6551

let m = egret.Matrix.create();
// 初始化
m.identity();
// 这里scalex/(scalex / canvasScaleX)约分之后其实就是1/canvasScaleX,也就是1/3。
m.scale(scalex / canvasScaleX, scaley / canvasScaleY);
m.rotate((rotation * Math.PI) / 180);
/** Matrix的6个参数
a,       b,           c,      d,     x,     y
|        | 旋转或者倾斜 |       |      |      |
|                             |      |      |
|_ _ _ _ _  缩放或旋转 _ _ _ _  |    x偏移    y偏移
*
*/
let transform = `matrix(${m.a},${m.b},${m.c},${m.d},${m.tx},${m.ty})`;
egret.Matrix.release(m);
canvas.style[egret.web.getPrefixStyleName("transform")] = transform;

上面代码最终呈现的效果是transform: matrix(0.333333, 0, 0, 0.333333, 0, 0),使得画布缩放至0.3333倍,通过updateStageSize(stageWidth, stageHeight)方法将画布宽高设置为原来的三倍以适配retina屏。

小结:当fixed_width下的不同手机屏幕设备像素不同时,只会影响内容的高度,这里的内容在egret中指的是舞台stage。回顾下egret对舞台的定义:

在这个树状结构中,处于最上层的是“舞台”。对应到程序中,是stage对象。舞台是Egret显示架构中最根本的显示容器。每个Egret应有且只有一个stage对象。舞台是这个显示树结构的根节点。

通过适配计算出来的内容宽高都将变成stage的宽高,而不是canvas对象的宽高。

其他的适配模式

EXACT\_FIT 不改变适口和内容宽高
  
FIXED\_HEIGHT

1.计算height缩放比例:scaleY = 视口高度/内容高度

2.改变内容宽度  :内容宽度\= 视口宽度/scale


NO\_BORDER

1.分别计算width/height的缩放比例:scaleX,scaleY

2.如果scaleX > scaleY: displayHeight = Math.round(stageHeight \* scaleX);

3.如果scaleY > scaleX: displayWidth = Math.round(stageWidth \* scaleY);


SHOW\_ALL

1.分别计算width/height的缩放比例:scaleX,scaleY

2.如果scaleX > scaleY : displayWidth = Math.round(stageWidth \* scaleY);

3..如果scaleY > scaleX: displayHeight = Math.round(stageHeight \* scaleX);

FIXED\_NARROW

1.分别计算width/height的缩放比例:scaleX,scaleY

2.如果scaleX > scaleY : stageWidth = Math.round(screenWidth / scaleY);

3.如果scaleY> scaleX :  stageHeight = Math.round(screenHeight / scaleX);

FIXED\_WIDE

1.  scaleX > scaleY : stageHeight = Math.round(screenHeight / scaleX)
2.  scaleY > scaleX: stageWidth = Math.round(screenWidth / scaleY)

Default

stageWidth = screenWidth;

stageHeight = screenHeight;

事件位置计算

事件中获取的x,y位置都是相对于视口的位置,因此需要将计算出相对于内容宽高的x,y

private getLocation(event:any):Point {
    event.identifier = +event.identifier || 0;
    let doc = document.documentElement;
    let box = this.canvas.getBoundingClientRect();
    let left = box.left + window.pageXOffset - doc.clientLeft;
    let top = box.top + window.pageYOffset - doc.clientTop;
    let x = event.pageX - left, newx = x;
    let y = event.pageY - top, newy = y;
    if (this.rotation == 90) {
        newx = y;
        newy = box.width - x;
    }
    else if (this.rotation == -90) {
        newx = box.height - y;
        newy = x;
    }
    // 除以视口和内容宽度比值
    newx = newx / this.scaleX;
     // 除以视口和内容的高度比值
    newy = newy / this.scaleY;
    return $TempPoint.setTo(Math.round(newx), Math.round(newy));
}
查看原文

赞 0 收藏 0 评论 0

Obeing 赞了回答 · 2019-12-11

解决移动端Web如何实现IOS双击事件。

可以这样实现

        var lastClickTime = 0;
        var clickTimer;
        document.getElementById('xxx').addEventListener('click', (event) => {
            var nowTime = new Date().getTime();
            if (nowTime - lastClickTime < 400) {
                /*双击*/
                lastClickTime = 0;
                clickTimer && clearTimeout(clickTimer);
                alert('双击');
                
            } else {
                /*单击*/
                lastClickTime = nowTime;
                clickTimer = setTimeout(() => {
                    alert('单击');
                }, 400);
            }
        });

关注 4 回答 3

Obeing 发布了文章 · 2019-11-30

白鹭引擎渲染优化 - CacheAsBitmap

CacheAsBitmap

这篇文章要从 egret 中的对象基类 DisplayObject 实例属性 cacheAsBitmap 说起。官方文档建议静态的UI使用建议设置 cacheAsBitmap 为 true 减少重绘次数。

如果设置为 true,则 Egret 运行时将缓存显示对象的内部位图表示形式。此缓存可以提高包含复杂矢量内容的显示对象的性能。

  • 将 cacheAsBitmap 属性设置为 true 后,呈现并不更改,但是,显示对象将自动执行像素贴紧。执行速度可能会大大加快,
  • 具体取决于显示对象内容的复杂性。最好将 cacheAsBitmap 属性与主要具有静态内容且不频繁缩放或旋转的显示对象一起使用。

为了一探究竟 cacheAsBitmap 是如何缓存和减少重绘次数,简单分析下源码。

// class DisplayObject
public set cacheAsBitmap(value: boolean) {
    ……
    self.$setHasDisplayList(value);
}

public $setHasDisplayList(value: boolean): void {
    ……
    if (value) {
        let displayList = sys.DisplayList.create(self);
        if (displayList) {
            this.$displayList = displayList;
            this.$cacheDirty = true;
        }
    }
    else {
        this.$displayList = null;
    }
}

可以看到设置 cacheAsBitmap 属性转化为设置 $displayList ,而这个属性值是 DisplayList 的实例,先不关心实例做了什么,等后续看到再展开。

DrawDisplayObject

找到了实际作用的属性 $displayList ,要在绘制图像方法中看这个属性到底起到了什么作用。我的办法比较笨,在全局搜 $displayList 属性,还好出现的文件不算多。在 class WebGLRenderer 中找到了绘制一个显示对象的方法 drawDisplayObject
简单来说 WebGLRenderer 这个类的作用就是当判断当前环境为 webgl 时使用的渲染类,跟它相对应的是 CanvasRenderer 类。

private drawDisplayObject(displayObject: DisplayObject, buffer: WebGLRenderBuffer, offsetX: number, offsetY: number, isStage?: boolean): number {
    // drawcall 表示绘制次数
    let drawCalls = 0;
    // 表示当前渲染节点
    let node: sys.RenderNode;
    let displayList = displayObject.$displayList;
    // isStage表示是否添加到舞台对象
    if (displayList && !isStage) {
        if (displayObject.$cacheDirty || displayObject.$renderDirty ||
            displayList.$canvasScaleX != sys.DisplayList.$canvasScaleX ||
            displayList.$canvasScaleY != sys.DisplayList.$canvasScaleY) {
            // 当渲染节点比例发生变化时,需要绘制根节点显示对象到目标画布,返回draw的次数。
            drawCalls += displayList.drawToSurface();
        }
        node = displayList.$renderNode;
    }
    else {
        // $renderDirty为true表示更换了渲染节点,需要重新获取渲染节点和清空drawData数据
        if (displayObject.$renderDirty) {
            node = displayObject.$getRenderNode();
        }
        else {
            node = displayObject.$renderNode;
        }
    }
 ……

isStage表示是否添加到舞台对象,根据文档,有且只有一个文档类容器会被添加到stage容器中
image.png

先来看 drawDisplayObject 前半部分的代码,可以看到设置了 cacheAsBitmap 与否决定了node 与 drawCall 的计算方式。

// drawDisplayObject方法
...
displayObject.$cacheDirty = false;
    if (node) {
        drawCalls++;
        buffer.$offsetX = offsetX;
        buffer.$offsetY = offsetY;
        // 这里是根据node type来做一些渲染,不影响drawcall
        switch (node.type) {
            case sys.RenderNodeType.BitmapNode:
                this.renderBitmap(<sys.BitmapNode>node, buffer);
                break;
        }
        buffer.$offsetX = 0;
        buffer.$offsetY = 0;
    }
    if (displayList && !isStage) {
        return drawCalls;
    }
    ...

这段代码中当渲染节点存在时增加一次 drawCall,然后如果 isStage 为false 且 cacheAsBitmap为true 函数就返回了,不进行后续的计算。于是全局搜了一下 drawDisplayObject 的调用,只有在主动调用render方法的时候 isStage 才为 true,意味着每一次render如果设置了cacheAsBitmap为true,到这里就不再产生渲染次数了。

image.png

// drawDisplayObject方法剩下的代码
let children = displayObject.$children;
if (children) {
    if (displayObject.sortableChildren && displayObject.$sortDirty) {
        //绘制排序
        displayObject.sortChildren();
    }
    let length = children.length;
    for (let i = 0; i < length; i++) {
        let child = children[i];
        let offsetX2;
        let offsetY2;
        let tempAlpha;
        let tempTintColor;
        if (child.$alpha != 1) {
            tempAlpha = buffer.globalAlpha;
            buffer.globalAlpha *= child.$alpha;
        }
        if (child.tint !== 0xFFFFFF) {
            tempTintColor = buffer.globalTintColor;
            buffer.globalTintColor = child.$tintRGB;
        }
        let savedMatrix: Matrix;
        if (child.$useTranslate) {
            let m = child.$getMatrix();
            offsetX2 = offsetX + child.$x;
            offsetY2 = offsetY + child.$y;
            let m2 = buffer.globalMatrix;
            savedMatrix = Matrix.create();
            savedMatrix.a = m2.a;
            savedMatrix.b = m2.b;
            savedMatrix.c = m2.c;
            savedMatrix.d = m2.d;
            savedMatrix.tx = m2.tx;
            savedMatrix.ty = m2.ty;
            buffer.transform(m.a, m.b, m.c, m.d, offsetX2, offsetY2);
            offsetX2 = -child.$anchorOffsetX;
            offsetY2 = -child.$anchorOffsetY;
        }
        else {
            offsetX2 = offsetX + child.$x - child.$anchorOffsetX;
            offsetY2 = offsetY + child.$y - child.$anchorOffsetY;
        }
        switch (child.$renderMode) {
            case RenderMode.NONE:
                break;
            case RenderMode.FILTER:
                drawCalls += this.drawWithFilter(child, buffer, offsetX2, offsetY2);
                break;
            case RenderMode.CLIP:
                drawCalls += this.drawWithClip(child, buffer, offsetX2, offsetY2);
                break;
            case RenderMode.SCROLLRECT:
                drawCalls += this.drawWithScrollRect(child, buffer, offsetX2, offsetY2);
                break;
            default:
                drawCalls += this.drawDisplayObject(child, buffer, offsetX2, offsetY2);
                break;
        }
        if (tempAlpha) {
            buffer.globalAlpha = tempAlpha;
        }
        if (tempTintColor) {
            buffer.globalTintColor = tempTintColor;
        }
        if (savedMatrix) {
            let m = buffer.globalMatrix;
            m.a = savedMatrix.a;
            m.b = savedMatrix.b;
            m.c = savedMatrix.c;
            m.d = savedMatrix.d;
            m.tx = savedMatrix.tx;
            m.ty = savedMatrix.ty;
            Matrix.release(savedMatrix);
        }
    }
}
return drawCalls;

剩下的代码是对当前渲染节点的子节点进行渲染计算,也就是说,设置了 cacheAsBitMap 的 DisplayObject 每次render只对子节点进行一次渲染。

小结

从源码分析了为什么 cacheAsBitMap 设置为 true 时可以减少ui的渲染次数,每次render通过减少子节点的渲染。因此将动静态节点分层然后在静态层设置 cacheAsBitMap 能够有效减少渲染次数。

查看原文

赞 3 收藏 0 评论 0

Obeing 赞了文章 · 2019-11-20

Mac vscode快捷键

全局

Command + Shift + P / F1 显示命令面板
Command + P 快速打开
Command + Shift + N 打开新窗口
Command + W 关闭窗口

基本

Command + X 剪切(未选中文本的情况下,剪切光标所在行)
Command + C 复制(未选中文本的情况下,复制光标所在行)
Option + Up 向上移动行
Option + Down 向下移动行
Option + Shift + Up 向上复制行
Option + Shift + Down 向下复制行
Command + Shift + K 删除行
Command + Enter 下一行插入
Command + Shift + Enter 上一行插入
Command + Shift + \ 跳转到匹配的括号
Command + [ 减少缩进
Command + ] 增加缩进
Home 跳转至行首
End 跳转到行尾
Command + Up 跳转至文件开头
Command + Down 跳转至文件结尾
Ctrl + PgUp 按行向上滚动
Ctrl + PgDown 按行向下滚动
Command + PgUp 按屏向上滚动
Command + PgDown 按屏向下滚动
Command + Shift + [ 折叠代码块
Command + Shift + ] 展开代码块
Command + K Command + [ 折叠全部子代码块
Command + K Command + ] 展开全部子代码块
Command + K Command + 0 折叠全部代码块
Command + K Command + J 展开全部代码块
Command + K Command + C 添加行注释
Command + K Command + U 移除行注释
Command + / 添加、移除行注释
Option + Shift + A 添加、移除块注释
Option + Z 自动换行、取消自动换行

多光标与选择

Option + 点击 插入多个光标
Command + Option + Up 向上插入光标
Command + Option + Down 向下插入光标
Command + U 撤销上一个光标操作
Option + Shift + I 在所选行的行尾插入光标
Command + I 选中当前行
Command + Shift + L 选中所有与当前选中内容相同部分
Command + F2 选中所有与当前选中单词相同的单词
Command + Ctrl + Shift + Left 折叠选中
Command + Ctrl + Shift + Right 展开选中
Alt + Shift + 拖动鼠标 选中代码块
Command + Shift + Option + Up 列选择 向上
Command + Shift + Option + Down 列选择 向下
Command + Shift + Option + Left 列选择 向左
Command + Shift + Option + Right 列选择 向右
Command + Shift + Option + PgUp 列选择 向上翻页
Command + Shift + Option + PgDown 列选择 向下翻页

查找替换

Command + F 查找
Command + Option + F 替换
Command + G 查找下一个
Command + Shift + G 查找上一个
Option + Enter 选中所有匹配项
Command + D 向下选中相同内容
Command + K Command + D 移除前一个向下选中相同内容

进阶

Ctrl + Space 打开建议
Command + Shift + Space 参数提示
Tab Emmet插件缩写补全
Option + Shift + F 格式化
Command + K Command + F 格式化选中内容
F12 跳转到声明位置
Option + F12 查看具体声明内容
Command + K F12 分屏查看具体声明内容
Command + . 快速修复
Shift + F12 显示引用
F2 重命名符号
Command + Shift + . 替换为上一个值
Command + Shift + , 替换为下一个值
Command + K Command + X 删除行尾多余空格
Command + K M 更改文件语言

导航

Command + T 显示所有符号
Ctrl + G 跳转至某行
Command + P 跳转到某个文件
Command + Shift + O 跳转到某个符号
Command + Shift + M 打开问题面板
F8 下一个错误或警告位置
Shift + F8 上一个错误或警告位置
Ctrl + Shift + Tab 编辑器历史记录
Ctrl + - 后退
Ctrl + Shift + - 前进
Ctrl + Shift + M Tab 切换焦点

编辑器管理

Command + W 关闭编辑器
Command + K F 关闭文件夹
Command + \ 编辑器分屏
Command + 1 切换到第一分组
Command + 2 切换到第二分组
Command + 3 切换到第三分组
Command + K Command + Left 切换到上一分组
Command + K Command + Right 切换到下一分组
Command + K Command + Shift + Left 左移编辑器
Command + K Command + Shift + Right 右移编辑器
Command + K Left 激活左侧编辑组
Command + K Right 激活右侧编辑组

文件管理

Command + N 新建文件
Command + O 打开文件
Command + S 保存文件
Command + Shift + S 另存为
Command + Option + S 全部保存
Command + W 关闭
Command + K Command + W 全部关闭
Command + Shift + T 重新打开被关闭的编辑器
Command + K Enter 保持打开
Ctrl + Tab 打开下一个
Ctrl + Shift + Tab 打开上一个
Command + K P 复制当前文件路径
Command + K R 在资源管理器中查看当前文件
Command + K O 新窗口打开当前文件

显示

Command + Ctrl + F 全屏、退出全屏
Command + Option + 1 切换编辑器分屏方式(横、竖)
Command + + 放大
Command + - 缩小
Command + B 显示、隐藏侧边栏
Command + Shift + E 显示资源管理器 或 切换焦点
Command + Shift + F 显示搜索框
Ctrl + Shift + G 显示Git面板
Command + Shift + D 显示调试面板
Command + Shift + X 显示插件面板
Command + Shift + H 全局搜索替换
Command + Shift + J 显示、隐藏高级搜索
Command + Shift + C 打开新终端
Command + Shift + U 显示输出面板
Command + Shift + V Markdown预览窗口
Command + K V 分屏显示 Markdown预览窗口

调试

F9 设置 或 取消断点
F5 开始 或 继续
F11 进入
Shift + F11 跳出
F10 跳过
Command + K Command + I 显示悬停信息

集成终端

Ctrl + ` 显示终端
Ctrl + Shift + ` 新建终端
Command + Up 向上滚动
Command + Down 向下滚动
PgUp 向上翻页
PgDown 向下翻页
Command + Home 滚动到顶部
Command + End 滚动到底部

顺便给媳妇打个广告

QQ20191030-195116.png

查看原文

赞 71 收藏 55 评论 4

Obeing 发布了文章 · 2019-10-27

Eggjs中的进程管理

Cluster

javscript的代码只能运行在单线程中,也就是一个nodejs进程只能运行在一个cpu上。如果需要充分利用多核cpu的并发优势,可以使用cluster模块。cluster能够创建多个子进程,每个进程都运行同一份代码,并且监听的是同一个端口。

简单利用Cluster fork cpu个数子进程的代码如下:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  // 如果是Master则进行fork操作,启动其他进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', function(worker, code, signal) {
    console.log('worker ' + worker.process.pid + ' died');
  });
} else {
  // 否则启动http服务监听
  http.createServer(function(req, res) {
    res.writeHead(200);
    res.end("hello world\n");
  }).listen(8000);
}
  • 为什么cluster fork多份源码跑在多个子进程上没有报端口被占用?
    cluster模块会hack掉worker中的监听,端口仅由master的TCP服务监听了一次

  • Master是如何处理请求转发的worker的?
    所有请求会统一经过内部TCP服务,符合一定负载均衡的挑选出一个worker并发送内部消息,该worker接收到消息后执行具体业务逻辑。(除 Windows 之外所有平台上的默认方法是循环方法,接受新的连接,并以循环方式将它们分发给各个工作线程,同时使用一些内置的智能,来实现负载均衡。)

EGG框架中的多进程

Agent机制

在eggjs中,除了有worker还有Agent,实际上也是一个worker,为了区别把他们称为agent worker和app worker。
Agent worker的作用是用来处理一些后台运行逻辑,比如说打印日志,不需要在4个app worker上都去执行,不对外提供服务,只处理公共事务,所以稳定性相对来说是很高的。

                +--------+          +-------+
                | Master |<-------->| Agent |
                +--------+          +-------+
                ^   ^    ^
               /    |     \
             /      |       \
           /        |         \
         v          v          v
+----------+   +----------+   +----------+
| Worker 1 |   | Worker 2 |   | Worker 3 |
+----------+   +----------+   +----------+

Master-Agent-Worker模型下,master承担了类似于pm2的进程管理的职责,能够完成worker的初始化/重启等工作。
image.png

进程守护

异常可以简单分为两类,第一类是可以监听process.on('uncaughtException', handler)捕获的异常,通过监听事件可以使得进程不会异常推出还有机会可以继续执行。第二类是被系统杀死直接推出的异常。
eggjs使用了graceful和egg-cluster让异常发生时master能够立刻fork出一个新的worker保持连接的worker数。

egg-cluster

进程启动顺序

+---------+           +---------+          +---------+
|  Master |           |  Agent  |          |  Worker |
+---------+           +----+----+          +----+----+
     |      fork agent     |                    |
     +-------------------->|                    |
     |      agent ready    |                    |
     |<--------------------+                    |
     |                     |     fork worker    |
     +----------------------------------------->|
     |     worker ready    |                    |
     |<-----------------------------------------+
     |      Egg ready      |                    |
     +-------------------->|                    |
     |      Egg ready      |                    |
     +----------------------------------------->|
  1. Master 启动后先 fork Agent 进程,同时监听'agent-exit, agent-start'事件,agent 启动成功后发送agent-start事件(IPC进程间通信)通知master

    • master监听到agent-exist事件会在一秒后再fork一次agent worker,保持agent稳定onAgentExit
    • agent-start事件为once,即使重启了agent app worker也不受影响
  2. Master收到 agent-start 通知fork多个App Worker,这里的fork用的是cfork包,负责 worker 的启动,状态监听以及 refork 操作,保证worker的数量
  3. 多个App worker 启动成功后发送app-start事件通知到master
  4. 所有的进程初始化成功后,Master 通知 Agent 和 Worker 应用启动成功

IPC进程通信

在nodejs中实现进程通信可以通过监听messgae事件实现

'use strict';
const cluster = require('cluster');

if (cluster.isMaster) {
  const worker = cluster.fork();
  worker.send('hi there');
  worker.on('message', msg => {
    console.log(`msg: ${msg} from worker#${worker.id}`);
  });
} else if (cluster.isWorker) {
  process.on('message', (msg) => {
    process.send(msg);
  });
}

在eggjs Agent机制中,agent也是也给worker,所以IPC通道存在与master和agent/app worker之间,而agent和app worker之间的通信需要通过master转发。
Eggjs包装了Message类,用fromto标记涞源和去向。

this.messenger.send({
      action: 'agent-exit',
      data: { code, signal },
      to: 'master',
      from: 'agent',
    });

参考链接

https://juejin.im/entry/59bcce1b5188257e82676b53
https://zhuanlan.zhihu.com/p/49276061
https://segmentfault.com/a/1190000018894188
https://eggjs.org/zh-cn/core/cluster-and-ipc.html

查看原文

赞 1 收藏 1 评论 0

认证与成就

  • 获得 132 次点赞
  • 获得 10 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-09-08
个人主页被 4.1k 人浏览