当对其他人展示自己开发的关系网络图时,经常被问类似这样的问题:如果数据量很大,它还能正常展示吗?或者是,它最多能展示多少个节点和边?一开始我并不在意这类问题,因为目前的业务确实没有在这方面遇到瓶颈。被问的多了之后,自己开始意识到随着业务的深入这个问题迟早是会摆在面前的。我何不提前思考,做好准备?
刚开始对此问题的思考重点在性能上,总感觉大数据就是对性能的考验。有一次产品同学在项目室里同大家讨论方案时说,需要控制下图中的节点数。原因是,节点太多了之后人脑无法快速的分析和甄别重要信息。所以这个问题由此转变到了,如何将“大数据”变成“小数据”。
展示交互优化
本章节统一介绍在展示层优化的方案,优化展示层的目的是让用户能快速甄别自己关心的数据。所以在实际项目中应用时必须认真分析当前业务需要解决的问题,然后根据业务问题灵活应用本章节中所介绍的方法。
节点分组聚合,展开详情
想要在密密麻麻的数据中甄别出有用的信息,那确实不是一个普通人能做的事情。《金字塔原理》中说可以通过归纳分组的方式减少一次性需要记忆的内容,然后再层层展开递进展示上一次。其实这种金字塔的内容组织形式在我们的数据中也经常使用到,数据经常被归类分组,类目常常成树形结构。同样的,这个结构形式在可视化领域也已经被广泛使用,最为常见的就是地图。全球地图的展示单位通常为七大洲,然后由洲放大展示国家,以此逐级放大细化展开,最后可以详细到某条街道、每一栋楼。在关系网络图中,我们是否也可以用这样的方式扩大颗粒度、减少颗粒数?答案是肯定的。
对于关系网络数据,可以根据节点的某一特定属性进行分组,也可以根据关系密度进行分组,还可以根据关系类型进行分组…… 或许还有其他更多目前我还没有看到过、没有想到的分组方式。不同的分组方式适用在不同的业务场景中,使用前需仔细甄别业务需求。示例为根据国家这一节点属性进行分组合并,然后还可以通过展开展示每个节点。
在分组后,还可以通过特殊图标标识出重点信息来引起用户的特别注意,展开的信息也不必所有的都展示出来,如下图所示。
关于分组后的节点,我们的信息也可以展示的更多。如下图所示我们还可使用甜甜圈图作为分组后的大节点中的一部分,展示大节点内数据分布情况。如图:
在节点上还可以发挥更多的想象力来展示更多的属性信息,但在图中并不是展示越多信息越好。需根据实际需求尽可能少的展示信息,因为展示无用信息只会是干扰用户的判断。
过滤干扰数据
在人工分析分析关系网络数据时,当很多的节点及其关系展现在画布上时是很难做分析的。这就如同我们经常说的千头万绪的状态。我们人类在处理千头万绪的复杂事务时,总是会想方设法找到一个点作为突破口、暂时的摒弃那些非重点干扰信息进行抽丝剥茧以解决问题。根据思维习惯,在分析复杂关系网络数据时我们会采用类似的思维模式——过滤干扰数据,寻找突破口,然后从突破口依次深入分析。基于此,平台需要提供一个能够过滤干扰数据的交互方式,如图:
我们可以使用边的属性、也可以是节点的属性进行过滤,图中示例通过节点的权重值过滤。
选择合适的布局算法
在关系网络图中最为常见也最容易被人们接受的布局方式为节点链接法。所谓节点连接法就是用顶点表示对象,用线(或边)表示关系,然后将节点用关系链接起来的方法。 此法使人很自然的通过连线建立事物与事物之间的关系,而数据结构的视觉解释依赖于布局算法。
比如我们希望了解节点间的层次结构就可以采用层次布局(树形)。在这里需要强调的是,并不是只有树形结构的数据才能排布成树。网图结构的数据,通过某种生成树算法也同样可以排布成树形结构的。只是在网图中节点被排布成层次结构之后关系依然是网状的,如图所示。
图中所示的层次结构跟我们通常所见的从左往右或从上往下的树形结构略有不同,它是一棵径向树。也就是,中间的红色节点为根节点,然后其外面的黄色节点为第二层,依次一圈圈的往外。采用这种径向树的优点:随着层次结构的加深节点数也会随之增多,而径向树在空间上也正好满足这一规律,越往外可利用空间越大;也因此此法的空间利用率很高。
在大数据的关系网络中,更为常见的布局方式是采用力导布局,这是一种通过模拟物理系统的布局算法。节点之间如同带电粒子般存在斥力,同时又像弹簧一样通过边牵扯着引力。在这些力的作用下产生运动,然后慢慢趋于一个平衡状态。此种布局方式中的每个节点的坐标是不确定的,且不反应任何特定变量;但它可以通过增大斥力使关系密度大的节点聚集,从而达到数据结构的模块化描述。
图中左(斥力小)右(斥力大)为两种不同斥力下的图形效果,明显的右图能更好的诠释节点间的关系密度。密度越大聚得越拢的节点集合的相关性越大,我们将其视为一个模块。这类分析侧重的是密度,图上所需要呈现的节点会很大量,此时可能会遇到性能瓶颈。
性能提升
绘图过程中出现的性能问题通常都在计算上,也就是在渲染过程中出现卡顿现象的性能问题。接下来我们讨论的几个解决方案都是解决这类性能问题的。
webGL
首当其冲的是启用webpGL,这个效果是立竿见影的,我们先看效果后说话。
测试例子:随机构建N个节点数据,然后使用此数据在画布上绘制方块,检测构建节点、绘制时间和。测试数据如下:
构建节点数 | canvas(ms) | webGL(ms) |
---|---|---|
2000 | 约10 | 约50 |
20000 | 约300 | 约80 |
200000 | 约3000 | 约200 |
800000 | 约12000 | 约500 |
根据以上表格的测试数据,我们得到以下三个有关性能的结论:
- 使用普通canvas绘制随着节点数的不断增加耗时也随之递增,而且增幅接近1:1。
- 使用webgl,随着节点数的增加,其耗时增幅要缓的多。
- 节点数越多,canvas和webGL的耗时差距越大。
既然webGL这么好,那么就直接上webGL吧?webGL随好,但它也有个致命的弱点,那就是代码复杂,开发成本高。至于webGL复杂在哪里,为什么复杂,有兴趣的同学可以搜索相关资料进行了解;笔者贴上示例代码,希望给读者有个直观的感受,但不做过多讨论。
// GLSL ES语言
// 顶点着色器
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'void main() {\n' +
' gl_Position = a_Position;\n' +
' gl_PointSize = 10.0;\n' +
'}\n';
// 片元着色器
var FSHADER_SOURCE =
'void main() {\n' +
' gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);\n' +
'}\n';
// webGL上下文
const gl = getWebGLContext(canvas);
initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE);
// mockData为生成模拟数据的方法
const { nodes } = mockData(800000, true);
const verts = [];
nodes.forEach((node) => {
verts.push(node.x);
verts.push(node.y);
});
const vertices = new Float32Array(verts);
// 创建缓存区
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(a_Position);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.POINTS, 0, nodes.length);
webGL之所以有如此大的威力那是因为它使用了GPU加速,至于为什么使用GPU之后速度就快推荐大家看《CPU和GPU的设计区别》一文。简单总结就是:CPU要处理的事情杂且多(有点像全才),什么事情都亲力亲为自然是要忙不过来的;GPU只干它擅长的事情(有点像专才),自然干得又快又好。
web workers
关于web workers想必大家肯定或多或少的听说过,它可以单独开辟出一个线程而对主线程无干扰。换言之,就是我们可以将部分计算移到web workers里去进行异步执行,结果返回后再融入到主线中;如此在web workers里执行时,不会阻塞主线里的任务执行。但是web workers有一个重要的限制是不能访问DOM和其他资源,基于这个限制它有很多事情是做不了的。
我们可以使用web workers做复杂数学计算、复杂数据排序、数据处理(压缩、图像处理…)、高流量网络通信这些事情。比如在上面webGL代码中,我们可以将21-26行代码的内容提取出来放到worker里进行处理;它可以为主线程剩下近400ms时间(以构建800000节点为例)。但上面的计算方式是一种理想状态,事实上开启web workers本身也是消耗资源的,实际耗时会比上述更长一些。另外,上面示例太过简单,当worker在运行时主线程反而闲着有些得不偿失。只有当将计算丢给worker运行,而主线程又不会闲着的时候才能真正体现出worker的价值来。下面的代码简单示意worker的使用方式,更多用法请自行查阅文档。
// 主线程代码
const worker = new Worker('./worker.js'); // worker.js为在worker线程中执行的代码文件
worker.postMessage({data}); // 向worker发送信息
worker.onmessage = function (evt) {
// 接收信息后的回调
}
// worker.js worder线程代码
onmessage = ()=>{
postMessage(); // worker向主线程发送消息
}
requestAnimationFrame
使用requestAnimationFrame是为了将代码切割成多个片段执行,以至于不阻塞其他更重要的代码执行(如用户的操作)。下面以渲染1亿个节点为例:
const width = 1000;
const height = 1000;
const offsetX = width/2;
const offsetY = height/2;
const container = document.getElementById('container');
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.border = '1px solid #ddd';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
const pre = 5000; // 每次渲染的节点数
const count = Math.floor(100000000/pre);
let times = 0;
start();
function start() {
if (times < count) { // 使用requestAnimationFrame分隔多次执行
requestAnimationFrame(start);
}
for (let i=0; i<pre; i++) {
const x = Math.random() * width;
const y = Math.random() * height;
ctx.beginPath();
ctx.arc(x, y, 15, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(255, 100, 0, .01)';
ctx.fill();
}
times ++;
}
在显然过程中,用户能够清晰的感觉到数据是被分批渲染上来的,但是不会有任何卡顿的感觉,总体来说体验还是不错的。如果不采用此法,用户就可能需要在那里不知所措的等上几十秒才能看到渲染结果。
requestAnimationFrame就像一个安慰剂,它只是让用户不明显的感受到页面的卡顿——那种让人窒息的感觉;但实际并不减少总耗时,事实上是有增无减。
总结
在大数据下的关系网络可视化中,笔者推荐优先使用交互优化,在交互无法进行优化时再使用性能优化方案。原因是,在交互上优化不仅仅是解决性能问题,是真正的从用户的角度出发解决用户做分析理解数据的工作时的体验问题。而提升性能肯定是在当前交互已经是目前能想到的方案中最好的方案的前提下才做,当交互本身无法让用户有好的体验时,性能再好那都是没有意义的。
另外,上面所述的方法均是笔者在学习过程中总结、思考所得,特别是交互部分并未在真实项目中实践过。故有纸上谈兵之嫌,若有分析理解不到位之处还烦请不吝赐教。
特别说明:文中图片源于网络,如有侵权请联系作者删除,谢谢!
**