scratch软件的逻辑不复杂,就是用blockly生成语句块,然后用虚拟机抽象成底层语法,最后再调用render渲染到界面,但是因为网上几乎没有资料,源代码又嵌套的极深,看起来还是很头疼的,所以我把我这一周看代码的心得分享一些出来,以后再慢慢更新.希望大家也能少走些弯路.
首先什么是虚拟机 : 用来屏蔽底层硬件差异和dom渲染差异 , 使得程序可以跨端移植 , react本质上也是虚拟机,虚拟dom屏蔽设备渲染差异( dom只有pc浏览器能识别 , 但虚拟dom是js对象 , 因而在手机上能解析成viewPort),native屏蔽底层硬件差异 ,使得程序可以在Android和ios都可以运行 . scratch-vm作用:使用虚拟io屏蔽底层差异,使用render屏蔽ui差异,使软件可以跨端
命名规则:静止状态的标签叫做target(包含stage和sprite),运行状态的标签叫做thread.舞台叫playgroud,渲染/停止渲染叫做grow/ungrow,监视器叫monitor(每个target可以有一个),backpack是背包,workspace是舞台上block的合集,runtime是内核.editingTarget是正在编辑的target,custom和backdrop都是target的皮肤,仅适用的对象不同,sprite的皮肤叫做custom,stage的皮肤叫做backdrop , custom可以有多个 ,因为多个皮肤帧可以产生动画
monitor 类似于看门狗,由于动画是连续快速渲染的,有时会渲出undefined等情况,这样会造成丢帧现象,所以我们把每一帧图像在update时强制用monitor进行检查,如果丢帧就回滚回上一帧,这样就感受不到丢帧了, monitor可以手动增删更新和隐藏显示,在vm/engine/runtime 在2076行左右
scratch运行时虚拟机分为标准模式(standard,按照默认机器频率刷新),兼容模式(compatibility,渲染速度是30tps,人眼会感受到画面频闪,但动作流畅性要好一些,可以兼容2.0的项目,2.0是as语言编写的,我猜测应该是使用的n制渲染来兼容差值扫描渲染,由于n制不是串行扫描,如果使用3.0模式的渲染可能会飙到120fps,所以他搞了这样一种兼容方案)和嵌入模式(加速模式,重绘次数会变少).
runtime:用于存储block,sprite和虚拟IO,内置一个sequencer队列(这是一个全局任务队列,每次对target操作都会入队,在js时钟tick时执行,直至为空为止),一个targets队列(每个tagers[i]生命周期与对应的target关联,target是局部的,可以被销毁,在需要时会重新创建,类似于路由模式,而sequencer是全局的,所有对target的操作都要在这里排队)
标签切换 在vm/engine/runtime 900行左右,主要控制在scratch中各个sprite的标签栏的切换,排序和销毁,标签不能被执行,除非接收到广播消息或者有用户交互时才会自执行.标签的管理在targets队列当中,标签之间的通信参见第七条
target并不是从runtime中初始化的,而是在vm外壳文件(virtual-machine)中通过io函数调用的,区分sb2,sb3的文件结构载入,downloadProjectId是从网络下载,loadProjectLocal是从本地加载,fromJSOM可以加载2.0版本(3.0有特殊的meta字段,2.0是as格式的脚本文件,如果载入之后发现是2.0,会zip压缩之后再blob二进制化,这样3.0版本就能识别了),项目工程载入之后才能installtarget,target类位于vm/engine/target,通过调用target中的函数就可以控制注册的block了,例如在lib/empty-assets中有个空的项目文件,到当项目加载时,调用了vm外壳中的的installTargets函数,会将target中的custom,objname,scripts等属性加载进来.
线程间的通信(重要) runtime管理着sprite,runtime与sprite之间用广播来通信,但当sprite之间需要通信时,这变的很复杂,所以runtime在线程调度层面实现了一个迷你redux,在dispatch文件夹中.central-dispatch是一个单例模式,会在vm外壳runtime初始化的时候调用他一次(virtual-machine第55行,坏点监控写的非常秀,值得借鉴)把vm设置成中央总线,他有一个services对象来注册全局路由,之后在文件中引入dispatch就可以在sprite之间互传参数了,在/extension-support/extension-manager中,初始化extension时调用dispatch.addWork(),会为extension新建一个工作线程,加入到线程池,由于在vm中installTargets时会生成一个extensionPromises队列,先异步加载ext再加载target,这样就保证了每个target至少会有一个worker,这样就实现了多线程计算,target中会被注入一个dispatch变量,使用dispatch就可以对总线状态进行推送(例如dispatch.call(serviceName)),进而实现了线程调度.
block渲染(重要) 当vm启动时,在runtime入口定义了defaultBlockPackages类,这里面声明了每个block块的功能函数(比如repeatUntil,moveTo等),在vm/engine/runtime 715行,有一个_registerBlockPackages函数,会加载所有block块动作,然后通过声明基类函数getPrimitives取得各个模块中的block预定义动作,之后通过订阅分发(路由模式)的方式生成packagePrimitives类,这样block块的特定功能就都可以在vm中使用了(此时已block已按category分类好),但是这种方式无法处理包含交互的block(如control和event,因为他们不仅要响应用户的操作,又要监听其他事件,如greenFlag被点击等),所以他们在基类中又扩展了hat类,其实就是一个二级路由,原理同上,类似的二级路由还有monitor类,具体功能参见第二条
关于hat,这一般是一个target程序的起点(除非有event的全局广播事件),vm启动时会在runtime维护一个hat队列,类似于microTick,vm内核中有一大堆类step(stepTreads,stepTread等等)函数会调用sequencer,sequencer中有个_step函数,_step会将所有队列中的函数执行完然后销毁自己,然后在新的tick如果产生新的任务则重启队列,所以hat队列是全局共享的.
关于虚拟IO,虚拟IO分为mouse,keyborad,BLE,clock等(名词解释:ble是蓝牙,bt是BT下载,clock是系统时钟,cloud是云端,keyboard是键盘,mouse是鼠标,mousewheel是鼠标滚轮滚动,video是视频渲染),stage中的所有按键,点击事件全部被注册到了lib/vm-listener-hoc中,然后从该文件中转发给对应的虚拟IO处理,当有点击事件发生时,vm-listener首先捕获事件,然后把消息推送至虚拟机,虚拟机会定位到对应的sprite或者带有hats的sprite,执行注册的函数.
虚拟IO的作用:scratch中自定义了一套key,每个key对应一个sprite动作(如uparrow,spaceDown等,scratch只认这套key,不认其他字符),虚拟键盘IO建立起了Ascll码与scratch-key之间的联系,而虚拟鼠标IO主要是捕获鼠标在stage上的位置(舞台中心默认为(0,0),是一个480360的区域,如果鼠标不在此区域,在查询鼠标位置时,会强制设置成边缘坐标,如左上角(-240,-180),如果在区域内,会通过遍历sprite的drawableID来找到target,然后调用target中对应的执行程序,所以在只在gui当中改变舞台位置,渲染区域并不会改变,因为超出480360的区域都被强制替换了),而虚拟ble,bt都是基于websocket的rpc,都是异步调用,与ajax的用法差不多
关于渲染:由于动作是线性并可预测结束位置(比如:step 5 ),所以渲染器在简单粗暴的比较前后两帧的区别,如果没有找到区别,则把上一帧直接作为动作结束并返回
targets = targets.filter(target => !!target); 这是对象去空操作
return !target.hasOwnProperty('isOriginal') || target.isOriginal; 这是剔除clone的操作,因为target.isOriginal为false时,代表这是clone的对象,由于isOriginal属性可能有也可能没有,直接判断target.isOriginal有可能会报错,所以要先加一个hasOwnProperty判断,如果不存在这个属性就不往后判断了
Object.is()比较两个对象是否严格相等,因为===有时候会有比较严重问题:如 +0 === -0 //true , NaN === NaN // false
static get RUNTIME_STARTED (){ return XXX},这是静态继承,他是属性不是函数,近似看成this.RUNTIME_STARTED就可以了,可以像这样全局调用vm.RUNTIME_STARTED
gui界面 在vm/engine/runtime 在500行左右通过静态继承定义,(更改舞台渲染器大小是在这里) 在外部使用vm.XXX调用,实质上是在调用函数的取值器get() (更改舞台大小和位置是个巨大的工程,因为scratch在虚拟IO(video,mouse),lib,gui的css和渲染器(render)中都定义了具体的数值,其他地方我暂时还没发现,稍有修改都可能引起bug)
gui界面的命名规则(重要):顶层蓝框(有sratch图标,可选语言)区域叫做menu-bar,下方选择代码/造型/声音区域叫做tab-list(点击可切换tabPanel),然后是下方:
一,左边的2/3区域:左侧舞台叫做gui-blocks(可以选择语句块并拖放出来),其中最左侧选择类型栏叫做(blocklyToolbox,包含上方的scratchCategoryMenu(内含运动,声音,外观等选项)和下方的gui_extension(添加扩展按钮)),中侧叫做blocklyFlyout(顾名思义,当拖拽block进入此区域会被删除)最右层叫做blocklyWorkspace(能够盛放拖拽出的block,他的右下角有一个blocklyZoom组件(用来控制代码块舞台的缩放),blocklyWorkspace还定义了一堆浏览器组件,比如ScrollbarVertical(这是一个水平滚动条),比如blocklyFlyoutBackground(这是一个描边功能),不能理解他为什么不用系统自带的,费力写这么一大片代码,然后看起来比系统默认的还要丑..)
二,右边1/3区域:上侧叫做stage,其中包含(stage-header和stage-canvas(渲染舞台)),下侧叫做gui-target,它是左右结构,左侧叫做sprite-selector(选择角色)(内含sprite-info(设置大小,方向,名称什么的),sprite-selector-item(角色图标)和sprite-selector-add-button(角色选择按钮)),右侧叫做target-pane(内含stage-selector-header(舞台字样),selector-costume-canvas(渲染舞台背景),stage-selector-add-button(舞台选择按钮))
与scratch通信:在lib/vm-listener-hoc中定义,主要是把vm当中导出的各种状态映射到reducer当中.
block定义结构,样式和行为,Toolbox定义类型,用于在workspace中分类显示,Blockly是总的控制函数.block分为block和shadow类型(shadow是占位符,不是块,提供block的默认值,当有其他其他块占据他的位置时会被覆盖),statement block指的是没有输出的block(只能在变量中添加,不能在流程中添加)
Blockly.Extensions.registerMutator(name, mixinObj, opt_helperFn, opt_blockList) 这是注册皮肤编辑器,Blockly.Extensions.register在scratch中被影射成了Blockly.ScratchBlocks.VerticalExtensions,而Blockly.ScratchBlocks追踪到最后是一个goog.require('Blockly.ScratchBlocks'),所有的goog.require函数链接一般指向的是输出文件夹的一个.compress.js文件,如果没有就是指向scratch-block/core文件夹,如果还没有就指向一个.py文件,可以直接编译出来,具体在package.json中配置
vm是在containers/gui.jsx中启动的,scratch中components是纯函数组件,而在containers文件夹中会把同名components与redux和vm连接,同时进行国际化,组件节流,版本控制,虚拟IO监听等操作,逻辑非常清晰.所有ui状态在reducer/gui.js中进行组装然后统一导出,但是要注意scratch根目录下的index.js是个假的入口文件,reducer真正是在lib/app-state-hoc中的AppStateHOC类组装的,这是一个中间件,在入口函数render-gui中GUI组件使用compose函数进行柯里化(将f(a)(b)(c)(d)变成f(a,b,c)(d)叫做柯里化)封装了AppStateHOC, HashParserHOC,TitledHOC三个中间件,而AppStateHOC通过判断是否需要加载paint和gui来加载不同的store,因此<Provider>也在这个组件当中,guiMiddleware是一个封装了throttle的柯里化函数,按照中间件模式调用,用于为组件节流(如果一秒内点了很多次,只会执行两次),封装之后返回了经过国际化(多语言模块)和节流处理的高级组件(节流的实现:当createStore拥有enhancer参数时,会返回一个enhancer(createStore)(reducer,state)的高级组件,这样使用enhancer就可以实现组件的功能模块化,类似于链式调用(一个函数只完成一个功能))
一次完整的vm调用过程,加载时先全局加载虚拟机,初始化虚拟IO(参见第10,11条),然后查看网络请求中location对象的hash,如果不能识别,直接在本地新建工程,并为工程赋予唯一ID值,如果能识别,从网络或从本地加载工程(参考第六条),将舞台推入render生成渲染器,再把渲染器推入vm,然后调用Blockly.inject函数在一个dom(类似于div#id)上面,初始化workspace和flyoutWorkspace样式(之所以有两个workspace,是因为workspace的宿主是target,当sprite销毁时这个workspace也就销毁了,而flyoutWorkspace宿主是vm,他负责将各个sprite中拖出来的block干掉,因此不能销毁,另外Blockly.inject的option对象中可以设置toolbox选项,能够加载外部xml,而且inject调用了core/DragSurfaceSvg,这是一个svg绘图程序,理论上应该也可以在这里渲染block出来,然而他只在这里渲染了两个workspace出来,不明白他为什么还要再去渲染一遍),然后为workspace建立blockListener(在vm/engine/block中定义,为block建立的通用(非特定)动作函数,如move,delete,create,click什么的,特定的block动作函数的加载参见第八条),然后为flyoutWorkspace建立flyoutBlockListener(追了好久发现居然和blockListener一毛一样,move事件(回调中有parent和input),change事件(回调中有name和value),所以如果需要给所有的block加统一的事件(仅限field和mutation)可以在这里添加 engine/blocks.js 第294行blocklyListen函数)
ui数据在reducer/gui.js中进行组装
guiMiddleware是一个封装了throttle的柯里化函数,按照中间件模式调用,用于为组件节流(如果一秒内点了很多次,只会执行两次)
index.js是个假的组装文件,reducer真正是在lib/app-state-hoc中的AppStateHOC类,这是一个中间件,在入口函数render-gui中GUI组件使用compose函数柯里化封装 AppStateHOC, HashParserHOC,TitledHOC三个中间件
AppStateHOC通过判断是否需要加载paint和gui来加载不同的store,<Provider>就在这个组件当中,返回了经过国际化(多语言模块)和节流处理的高级组件(节流的实现:当createStore拥有enhancer函数时,会返回一个enhancer(createStore)(reducer,state)的高级组件,这样使用enhancer就可以实现组件的功能模块化,类似于链式调用(一个函数只完成一个功能))