-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcontent.json
More file actions
1 lines (1 loc) · 220 KB
/
Copy pathcontent.json
File metadata and controls
1 lines (1 loc) · 220 KB
1
[{"title":"TinyCocosFix -- Cocos热更新的非官方解决方案","date":"2021-02-22T15:16:30.000Z","path":"2021/02/22/TinyCocosFix/","text":"Cocos官方提供了一套热更新的解决方案,但是实际使用的过程中没有办法完全满足我们的需求,我们提出了一套自己的解决方案。 背景Cocos 官方提供了一套热更新的解决方案,但我们认为这套方案在以下方面不能完全适合我们的需求: 必须要在 Cocos 引擎启动之后才能够开始热更新流程,而我们有些业务场景需要支持在 Cocos 引擎未启动的时候就能够开始热更新流程; 文件下载效率低,官方提供的方案是在客户端本地对比本地的文件 manifest 和服务端的文件 manifest,找出其中的差异,然后再将差异的文件下载下来,并逐个校验,效率较低; 没有回退兜底策略,若本地进行热更新之后出现异常导致用户无法使用,无法回退到上一个版本; 基于这些问题,我们给出了自己的 Cocos 热更新解决方案 TinyCocosFix。TinyCocosFix 是基于 native 端实现,因此能够在 Cocos 引擎未启动时就开始热更新流程;我们将文件 diff 的逻辑放在服务端,服务端直接将两个版本的 diff 文件压缩成 zip 包返回给客户端,然后客户端再针对 zip 包做校验,整个 diff 的过程比官方的方案更加高效;另外,我们还在本地做了历史版本管理,当新的热更新版本出现异常,导致用户无法使用时,可以启动兜底策略,本地回滚到上一个版本,尽最大可能保证用户体验。 可行性分析根据 Cocos 的官方文档,https://docs.cocos.com/creator/manual/zh/advanced-topics/assets-manager.htmlCocos 所有的文件均通过 FileUtils 查找,而 FileUtils 会按照搜索路径的优先顺序查找文件,所以理论上,我们只需要将热更新的文件下载下来,放到指定的目录,并将该目录添加到 FileUtils 的搜索路径的前面,这样我们就可以实现热更新了。 经过 demo 测试,验证了我们的猜想。因此整个热更新的原理其实比较简单,客户端携带版本号等相关参数请求后台,后台收到App的请求之后判断是否有热更新,若没有,则返回无;若有,则返回热更新资源包。客户端下载完热更新资源之后,将对应的路径插入到 FileUtils 搜索路径当中,即完成了热更新的过程。 TinyCocosFix的总体设计整个热更新的流程如下图所示,根据角色分为前端,后台和客户端;当我们发布新的 App 版本时,需要告诉热更新管理平台,并上传当前 App 的资源包,这时候的资源包为 base 包;当我们需要发布热更新的时候,此时需要上传热更新资源包,管理平台可以指定该热更新资源包能够更新到哪个App版本,并和之前的资源包生成 diff 包;当对应版本的客户端请求后台时,便会返回相应的 diff 包给客户端。 1.后台链式管理管理后台使用链式的方式对 App 版本和热更新版本进行管理,具体如下图所示:我们发布热更包的时候可以自由的指定这个热更包能够在哪个 App 版本上生效。例如当我发布热更新1.0版本,指定在4.3.5和4.4.0上生效,则在其后追加1.0;当发布2.0时,1.0自动过期;当发布3.0,指定在4.4.0和4.4.5生效时,4.4.0的2.0热更包过期,4.3.5的2.0继续生效。 生成最近5个diff包当我们在管理后台创建热更新包时,会找最近的5个热更包生成 diff 包,如下图所示:App 版本4.4.0已经发布了6个热更新版本,当我们创建7.0热更新版本时,会自动找最近的5个版本生成 diff 包,此时,如果客户端的App版本是4.4.0,热更新版本为2.0 - 6.0,则会直接返回对应的diff包;如果客户端的App版本是4.4.0,热更新版本为1.0,则第一次请求,后台会返回没有热更包,并触发对应1.0和7.0的 diff 包生成逻辑,下一次请求就会返回对应的热更包。 蓝盾流水线创建App版本后台提供了相应的接口,支持在流水线上自动创建热更新版本。 接口如下: 2.客户端客户端的热更新流程如下: 热更新策略我们定义了三种热更新策略 热更新策略 特点 强制更新 不允许取消,热更新包下载完成之后立即重启生效 推荐更新 允许取消和跳过 静默更新 后台下载,第二次启动生效,用户无感知 历史版本管理TinyCocosFix 会在本地最多保留三个历史版本,并对外提供了回退到上一个版本的接口,业务方可根据自己的需求来定义触发回滚的逻辑。如下图所示,为 ABCmouse 的回滚策略:我们会在主要场景检测发生的异常,若在一分钟内发生超过50次异常,且本地发生过热更新,则触发回滚机制。回滚完成之后 TinyCocosFix 内部会上报触发回滚的异常,并记录发生回滚的版本,若下次检测到相同的版本,则不会提示有热更新。 异常管理TinyCocosFix 内部会对异常进行管理,主要分为热更新过程异常和业务方的热更包自身逻辑异常,具体的处理方式如下表所示: 异常 处理 备注 热更新过程异常 记录热更新失败版本,下次遇到相同版本直接跳过 如多次 MD5 校验失败 热更包自身逻辑异常 TinyCocosFix 提供回滚接口,业务方自定义错误阈值,触发回滚,回滚之后记录回滚的版本号,下次遇到相同版本跳过 兜底策略 断点续传若热更新下载过程中进程突然被杀掉,下一次启动会从上次下载的地方继续下载。 3.热更管理平台主界面热更新管理平台界面如下: 目前管理平台包括四个部分:App 版本管理,热更包版本管理,灰度用户管理,开发者管理。App 版本管理创建App版本界面如下:我们 App 每次发版时,需要在管理平台创建对应的App版本,这样发布热更新时,我们才能够指定热更新支持的版本,后台也能够根据每个版本生成 diff 包,我们需要提供 App 的版本号以及对应的资源包。这一步可以在蓝盾上使用流水线自动实现。 热更包版本管理创建热更包需要提供热更新的资源包,并选择热更新策略,在下一步可以选择热更新生效的App版本。 灰度用户管理在这里配置的灰度用户适用于精确灰度,当我们的热更包还处于自测阶段时,只有灰度id才能够收到热更包。 开发者管理只有拥有权限的用户才能够进入到管理平台。 热更新发布流程我们规范了热更新的发布流程,如下图所示: 我们认为发布是一个很危险的操作,任何发布的热更包都必须要经过测试,因此新创建的热更包只能是灰度包;精确灰度管理后台有一个灰度账号管理,创建的精确灰度包能够匹配到这些账号,这一步主要用于我们内部测试;批量灰度当我们精确灰度没问题之后,便可以进行下一个步骤,批量灰度。目前支持尾号匹配和上传 excel 两种批量灰度规则。尾号匹配,例如用户的id为12345678,尾号匹配为8或者78,均能匹配到该用户。有时我们发布热更包希望只有某些确定的用户能够收到,则可以使用上传 excel 的功能,excel 表格中包含所有批量灰度的用户。发布批量灰度一段时间之后,观察下热更新的成功率和失败率以及回滚率,如果没有问题,便可以发布了。在批量灰度和发布期间均可以将热更包过期,一旦过期热更包立即失效,过期的热更包可以恢复到之前的状态。 4.上报统计TinyCocosFix 会上报三类信息,具体如下|类别|备注||–|–|| 初始化上报 | 统计热更新版本存量用户|| 热更新结果上报 | 统计热更新成功率和失败率|| 回滚上报 | 统计回滚率|上报结果统计界面如下: 5.和官方热更新方案对比 官方 TinyCocosFix 本地计算 diff 后台计算 diff 每次下载一个文件,单个校验 下载整个 diff 包,整体校验 md5 必须启动 Cocos 引擎 无需启动,native 端可完成热更新流程 无回滚策略 增加回滚策略,出现问题回滚到上个版本 下一步规划1.权限控制目前管理平台还未分角色做权限控制,下一步,我们将会针对不同的角色给出相应的权限,以下是初步的规划: 根据发布的不同阶段,我们将角色分为开发,测试和管理员。开发只拥有精确灰度的权限,用于开发自测;测试拥有精确灰度和批量灰度的权限;管理员拥有最高权限。所以整个发布的流程应当如下图所示:开发完成之后,使用精确灰度进行自测,自测没问题之后便交付给测试同学;测试同学测试通过后,便可以开始进行批量灰度,灰度一段时间观察没问题之后,测试便可以向管理员申请发布;管理员收到申请之后,经过审批,热更新正式全量发布。 2.完善监控和报警机制每次发布新的热更新版本之后,每日邮件同步热更新结果,包括热更新成功率,失败率和回滚率以及最近三个热更版本的存量。当热更新失败率超过一定的阈值,触发报警,阈值给定默认值,用户也可以设置。 3.正式环境和测试环境目前管理后台并未区分正式环境和测试环境,ABCmouse 是通过 debug 和 release 环境,指定不同的 appId,以此来达到区分正式环境和测试环境的目的。下一步,TinyCocosFix 将在 sdk 内部支持正式和测试环境,将正式和测试环境在物理上进行区隔。正式环境将会更加稳定,不会受到测试的影响。而在测试环境我们可以更加方便的测试,而不用担心影响到现网用户。 感谢感谢一起参与设计和实现热更新方案的小伙伴们。@josephpan(潘伟洲) @mariozheng(郑磊) @ezli(李剑飞) @aflextyang(杨波) @shinhachen(陈新华) @erikyi(衣文琦) @irisxxiao(肖湘) @xuyangfan(范旭阳)","tags":[{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"},{"name":"热更新","slug":"热更新","permalink":"http://oedx.github.io/tags/热更新/"}]},{"title":"基于 FFmpeg 的 Cocos Creator 视频播放器","date":"2020-12-29T17:03:37.000Z","path":"2020/12/29/FFmpeg-Cocos-Creator/","text":"只为解决一个核心问题,追求更好体验。 背景腾讯开心鼠项目使用的游戏引擎是 Cocos Creator,由于引擎提供的视频组件实现方式问题导致视频组件和游戏界面分了层,从而导致了以下若干问题: 不可以在视频组件上添加其他渲染组件; 不可以使用遮罩组件来限定视频形状; 退出场景时存在视频组件残影; 等等… 核心问题就是分层问题,对于开心鼠项目带来的最大弊端就是:一套设计,Android,iOS,Web 三端需要各自实现,开发和维护成本高,又因为平台差异化,还存在视觉不一致和表现不一致问题。 解决方案 因为开心鼠项目需要兼容 Android,iOS 和 Web 三端,Android 和 iOS 一起视为移动端,所以解决方案有以下两点: 移动端可使用 FFmpeg 库解码视频流,然后使用 OpenGL 来渲染视频,和使用 Andorid, iOS 两端各自的音频接口来播放音频; 网页端可以直接使用 video 元素来解码音视频,然后使用 WebGL 来渲染视频,和使用 video 元素来播放音频。 任务细分 任务详情移动端 ffplay 播放音视频FFmpeg 官方源码,可以编译出三个可执行程序,分别是 ffmpeg, ffplay, ffprobe ,三者作用分别是: ffmpeg 用于音视频视频格式转换,视频裁剪等; ffplay 用于播放音视频,需要依赖 SDL; ffprobe 用于分析音视频流媒体数据。 其中 ffplay 程序满足了播放音视频的需求,理论上,只要把 SDL 视频展示和音频播放接口替换成移动端接口,就能完成 Cocos Creator 的音视频播放功能,但在实际 ffplay 改造过程中,还是会遇到很多小问题,例如:在移动端使用 swscale 进行纹理缩放和像素格式转换效率低下,不支持 Android asset 文件读取问题等等,下文会逐一解决。经过一系列改造后,Cocos Creator 可用的 AVPlayer 诞生了。以下为 AVPlayer 播放音视频流程分析: 概括: 调用 stream_open 函数,初始化状态信息和数据队列,并创建 read_thread 和 refresh_thread; read_thread 主要职责为打开流媒体,创建解码线程(音频,视频,字幕),读取原始数据; 解码线程分别解码原始数据,得到视频图片序列,音频样本序列,字幕字符串序列; 在创建音频解码器过程中,同时打开了音频设备,在播放过程中,会不断消耗生成的音频样本; refresh_thread 主要职责为不断消耗视频图片序列和字幕字符串序列。 ffplay 改造后的 AVPlayer UML如下: 声明:因为本人少接触 c 和 c++ ,所以在 ffplay 改造过程中,SDL 线程改造和字幕分析参考了 bilibili 的 ijkplayer 源码。 JSB 绑定视频组件接口此节不适合 Web 端,关于 JSB 相关知识,可查阅文档:JSB 2.0 绑定教程 概括 JSB 功能:通过 ScriptEngine 暴露的接口绑定 JS 对象和其他语言对象,让 JS 对象控制其他语言对象。 因为播放器逻辑使用 C 和 C++ 编码,所以需要绑定 JS 和 C++ 对象。上文中的 AVPlayer 只负责解码和播放流程,播放器还需要处理入参处理,视频渲染和音频播放等工作,因此封装了一个类:Video,其 UML 如下: Video.cpp 绑定的 JS 对象声明如下: 1234567891011121314151617181920212223242526272829303132333435363738bool js_register_video_Video(se::Object *obj) { auto cls = se::Class::create(\"Video\", obj, nullptr, _SE(js_gfx_Video_constructor)); cls->defineFunction(\"init\", _SE(js_gfx_Video_init)); cls->defineFunction(\"prepare\", _SE(js_gfx_Video_prepare)); cls->defineFunction(\"play\", _SE(js_gfx_Video_play)); cls->defineFunction(\"resume\", _SE(js_gfx_Video_resume)); cls->defineFunction(\"pause\", _SE(js_gfx_Video_pause)); cls->defineFunction(\"currentTime\", _SE(js_gfx_Video_currentTime)); cls->defineFunction(\"addEventListener\", _SE(js_gfx_Video_addEventListener)); cls->defineFunction(\"stop\", _SE(js_gfx_Video_stop)); cls->defineFunction(\"clear\", _SE(js_gfx_Video_clear)); cls->defineFunction(\"setURL\", _SE(js_gfx_Video_setURL)); cls->defineFunction(\"duration\", _SE(js_gfx_Video_duration)); cls->defineFunction(\"seek\", _SE(js_gfx_Video_seek)); cls->defineFunction(\"destroy\", _SE(js_cocos2d_Video_destroy)); cls->defineFinalizeFunction(_SE(js_cocos2d_Video_finalize)); cls->install(); JSBClassType::registerClass<cocos2d::renderer::Video>(cls); __jsb_cocos2d_renderer_Video_proto = cls->getProto(); __jsb_cocos2d_renderer_Video_class = cls; se::ScriptEngine::getInstance()->clearException(); return true;}bool register_all_video_experiment(se::Object *obj) { se::Value nsVal; if (!obj->getProperty(\"gfx\", &nsVal)) { se::HandleObject jsobj(se::Object::createPlainObject()); nsVal.setObject(jsobj); obj->setProperty(\"gfx\", nsVal); } se::Object *ns = nsVal.toObject(); js_register_video_Video(ns); return true;} 概括:以上声明,表示可在 JS 代码中,使用以下方法 123456789101112131415161718192021222324252627let video = new gfx.Video(); // 构造函数video.init(cc.renderer.device, { // 初始化参数 images: [], width: videoWidth, height: videoHeight, wrapS: gfx.WRAP_CLAMP, wrapT: gfx.WRAP_CLAMP,});video.setURL(url); // 设置资源路径video.prepare(); // 调用准备函数video.play(); // 播放video.pause(); // 暂停video.resume(); // 恢复video.stop(); // 停止video.clear(); // 清理video.destroy(); // 销毁video.seek(position); // 跳转let duration = video.duration(); // 获取视频时长let currentTime = video.currentTime(); // 获取当前播放位置video.addEventListener('loaded', () => {}); // 监听 Meta 加载完成事件video.addEventListener('ready', () => {}); // 监听准备完毕事件video.addEventListener('completed', () => {}); // 监听播放完成事件video.addEventListener('error', () => {}); // 监听播放失败事件 视频展示,纹理渲染实现视频展示功能,需要先了解纹理渲染流程,由于 Cocos Creator 在移动端使用的是 OpenGL API,在 Web 端使用的 WebGL API,OpenGL API 和 WebGL API 大致相同,因此可以到 OpenGL 网站学习下纹理渲染流程。初学者,推荐到 LearnOpenGL CN 学习。接下来使用 LearnOpenGL CN 纹理章节讲解以下纹理渲染流程。 顶点着色器: 1234567891011#version 330 corelayout (location = 0) in vec3 aPos;layout (location = 1) in vec2 aTexCoord;out vec2 TexCoord;void main(){ gl_Position = vec4(aPos, 1.0); TexCoord = vec2(aTexCoord.x, aTexCoord.y);} 片段着色器: 1234567891011#version 330 coreout vec4 FragColor;in vec2 TexCoord;uniform sampler2D tex;void main(){ FragColor = texture(tex, TexCoord);} 纹理渲染程序: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132#include <glad/glad.h>#include <GLFW/glfw3.h>#include <stb_image.h>#include <learnopengl/shader_s.h>#include <iostream>// 窗口大小const unsigned int SCR_WIDTH = 800;const unsigned int SCR_HEIGHT = 600;int main(){ // 初始化窗口 // -------- GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, \"LearnOpenGL\", NULL, NULL); ... // 编译和链接着色器程序 // ---------------- Shader ourShader(\"4.1.texture.vs\", \"4.1.texture.fs\"); // 设置顶点属性参数 // ------------- float vertices[] = { // 位置 // 纹理坐标 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // top right 0.5f, -0.5f, 0.0f, 1.0f, 1.0f, // bottom right -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // bottom left -0.5f, 0.5f, 0.0f, 0.0f, 0.0f // top left }; // 设置索引数据,此程序画的图形基元是三角形,图片为矩形,所以由两个三角形组成 unsigned int indices[] = { 0, 1, 3, // first triangle 1, 2, 3 // second triangle }; // 声明和创建 VBO 顶点缓冲对象,VAO 顶点数组对象,索引缓冲对象 // C 语言并非面向对象编程,这里使用无符号整形来代表对象 unsigned int VBO, VAO, EBO; glGenVertexArrays(1, &VAO); glGenBuffers(1, &VBO); glGenBuffers(1, &EBO); // 绑定顶点对象数组,可记录接下来设置的缓冲对象数据,方便在渲染循环中使用 glBindVertexArray(VAO); // 绑定顶点缓冲对象,用于传递顶点属性参数 glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // 绑定索引缓冲对象,glDrawElements 会按照索引顺序画图形基元 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); // 链接顶点属性:位置, 参数:索引,大小,类型,标准化,步进,偏移 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // 链接顶点属性:纹理坐标,参数:索引,大小,类型,标准化,步进,偏移 glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); glEnableVertexAttribArray(1); // 生成纹理对象 // ------------------------- unsigned int texture; glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); // 设置环绕参数 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); // 设置纹理过滤 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 加载图片 int width, height, nrChannels; unsigned char *data = stbi_load(FileSystem::getPath(\"resources/textures/container.jpg\").c_str(), &width, &height, &nrChannels, 0); if (data) { // 传递纹理数据,参数:目标,级别,内部格式,宽,高,边框,格式,数据类型,像素数组 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data); glGenerateMipmap(GL_TEXTURE_2D); } else { std::cout << \"Failed to load texture\" << std::endl; } stbi_image_free(data); // 渲染循环 // ------- while (!glfwWindowShouldClose(window)) { // 清理 // --- glClearColor(0.2f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); // 绑定纹理 glBindTexture(GL_TEXTURE_2D, texture); // 应用 shader 程序 ourShader.use(); // 绑定顶点数组对象 glBindVertexArray(VAO); // 绘制三角形基元 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); // glfw 交换缓冲 glfwSwapBuffers(window); } // 清理对象 // ------------------------------------------------------------------------ glDeleteVertexArrays(1, &VAO); glDeleteBuffers(1, &VBO); glDeleteBuffers(1, &EBO); // 结束 // ------------------------------------------------------------------ glfwTerminate(); return 0;} 简单的纹理渲染流程: 编译和链接着色器程序; 设置顶点数据,包括位置和纹理坐标属性(值得注意的是:位置坐标系和纹理坐标系不同,下文介绍); 设置索引数据,索引是用来绘制图形基元时参照; 创建顶点缓冲对象,索引缓冲对象,顶点数组对象,并绑定传值; 链接顶点属性; 创建和绑定纹理对象,加载图片,传递纹理像素值; 让程序进入渲染循环,在循环中绑定顶点数组对象,不断绘制图形基元。 第2点描述的位置坐标系和纹理系不同,具体不同如下图: 位置坐标系原点(0,0)在中心位置,x,y 取值范围是 -1 到 1; 纹理坐标系原点(0,0)在左上角位置,x,y取值范围是 0 到 1; 在 Cocos Creator 2.0 版本后,自定义渲染组件,分为三步: 自定义材质(材质负责着色器程序); 自定义 Assembler (Assembler 负责传递顶点属性); 设置材质动态参数,如设置纹理,变换平移旋转缩放矩阵等。 第 1 步:着色器程序需要写在 effect 文件中,而 effect 被 material 使用,每个渲染组件,需要挂载 material 属性。由于视频展示,可以理解为图片帧动画渲染,因此可以直接使用 Cocos Creator 提供的 CCSprite 所用的 builtin-2d-sprite 材质。 第 2 步:有了材质后,只需要关心位置坐标和纹理坐标传递,即要自定义 Assembler,可参考官方文档 自定义 Assembler。为了效率,直接使用官方源码 https://github.com/cocos-creator/engine/blob/master/cocos2d/core/renderer/assembler-2d.js 改造,值得注意的是,原生端和 Web 端世界坐标计算方式( updateWorldVerts )不一样,否则会出现展示位置错乱问题。直接贴代码: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181export default class CCVideoAssembler extends cc.Assembler { constructor () { super(); this.floatsPerVert = 5; this.verticesCount = 4; this.indicesCount = 6; this.uvOffset = 2; this.colorOffset = 4; this.uv = [0, 1, 1, 1, 0, 0, 1, 0]; // left bottom, right bottom, left top, right top this._renderData = new cc.RenderData(); this._renderData.init(this); this.initData(); this.initLocal(); } get verticesFloats () { return this.verticesCount * this.floatsPerVert; } initData () { this._renderData.createQuadData(0, this.verticesFloats, this.indicesCount); } initLocal () { this._local = []; this._local.length = 4; } updateColor (comp, color) { let uintVerts = this._renderData.uintVDatas[0]; if (!uintVerts) return; color = color || comp.node.color._val; let floatsPerVert = this.floatsPerVert; let colorOffset = this.colorOffset; for (let i = colorOffset, l = uintVerts.length; i < l; i += floatsPerVert) { uintVerts[i] = color; } } getBuffer () { return cc.renderer._handle._meshBuffer; } updateWorldVerts (comp) { let local = this._local; let verts = this._renderData.vDatas[0]; if(CC_JSB){ let vl = local[0], vr = local[2], vb = local[1], vt = local[3]; // left bottom verts[0] = vl; verts[1] = vb; // right bottom verts[5] = vr; verts[6] = vb; // left top verts[10] = vl; verts[11] = vt; // right top verts[15] = vr; verts[16] = vt; }else{ let matrix = comp.node._worldMatrix; let matrixm = matrix.m, a = matrixm[0], b = matrixm[1], c = matrixm[4], d = matrixm[5], tx = matrixm[12], ty = matrixm[13]; let vl = local[0], vr = local[2], vb = local[1], vt = local[3]; let justTranslate = a === 1 && b === 0 && c === 0 && d === 1; if (justTranslate) { // left bottom verts[0] = vl + tx; verts[1] = vb + ty; // right bottom verts[5] = vr + tx; verts[6] = vb + ty; // left top verts[10] = vl + tx; verts[11] = vt + ty; // right top verts[15] = vr + tx; verts[16] = vt + ty; } else { let al = a * vl, ar = a * vr, bl = b * vl, br = b * vr, cb = c * vb, ct = c * vt, db = d * vb, dt = d * vt; // left bottom verts[0] = al + cb + tx; verts[1] = bl + db + ty; // right bottom verts[5] = ar + cb + tx; verts[6] = br + db + ty; // left top verts[10] = al + ct + tx; verts[11] = bl + dt + ty; // right top verts[15] = ar + ct + tx; verts[16] = br + dt + ty; } } } fillBuffers (comp, renderer) { if (renderer.worldMatDirty) { this.updateWorldVerts(comp); } let renderData = this._renderData; let vData = renderData.vDatas[0]; let iData = renderData.iDatas[0]; let buffer = this.getBuffer(renderer); let offsetInfo = buffer.request(this.verticesCount, this.indicesCount); // fill vertices let vertexOffset = offsetInfo.byteOffset >> 2, vbuf = buffer._vData; if (vData.length + vertexOffset > vbuf.length) { vbuf.set(vData.subarray(0, vbuf.length - vertexOffset), vertexOffset); } else { vbuf.set(vData, vertexOffset); } // fill indices let ibuf = buffer._iData, indiceOffset = offsetInfo.indiceOffset, vertexId = offsetInfo.vertexOffset; for (let i = 0, l = iData.length; i < l; i++) { ibuf[indiceOffset++] = vertexId + iData[i]; } } updateRenderData (comp) { if (comp._vertsDirty) { this.updateUVs(comp); this.updateVerts(comp); comp._vertsDirty = false; } } updateUVs (comp) { let uv = this.uv; let uvOffset = this.uvOffset; let floatsPerVert = this.floatsPerVert; let verts = this._renderData.vDatas[0]; for (let i = 0; i < 4; i++) { let srcOffset = i * 2; let dstOffset = floatsPerVert * i + uvOffset; verts[dstOffset] = uv[srcOffset]; verts[dstOffset + 1] = uv[srcOffset + 1]; } } updateVerts (comp) { let node = comp.node, cw = node.width, ch = node.height, appx = node.anchorX * cw, appy = node.anchorY * ch, l, b, r, t; l = -appx; b = -appy; r = cw - appx; t = ch - appy; let local = this._local; local[0] = l; local[1] = b; local[2] = r; local[3] = t; this.updateWorldVerts(comp); }} 第 3 步,设置材质动态参数,在视频播放器中,需要动态修改的就是纹理数据了,在移动端,ffplay 改造后的 AVPlayer 在播放过程,通过 ITextureRenderer.render(uint8_t) 接口调用到 void Video::setImage(const uint8_t *data) 方法,实际在不断更新纹理数据,代码如下: 12345678910111213141516void Video::setImage(const uint8_t *data) { GL_CHECK(glActiveTexture(GL_TEXTURE0)); GL_CHECK(glBindTexture(GL_TEXTURE_2D, _glID)); GL_CHECK( glTexImage2D(GL_TEXTURE_2D, 0, _glInternalFormat, _width, _height, 0, _glFormat, _glType, data)); _device->restoreTexture(0);}在 Web 端,则是在 CCVideo 渲染组件的每一帧去传递 video 元素,代码如下:let gl = cc.renderer.device._gl;this.update = dt => { if(this._currentState == VideoState.PLAYING){ gl.bindTexture(gl.TEXTURE_2D, this.texture._glID); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.video); }}; 至此,视频展示小节完毕。 音频播放在改造音频播放过程之前,查阅了 ijkplayer 的音频播放方案,作为现状分析。 ijkplayer 在 Android 端有两套方案:AudioTrack 和 OpenSL ES。 AudioTrack 属于那种同步写数据的方式,属于 “推” 方案,google 最开始推行的方式,估计比较稳定,由于 AudioTrack 是 Java 接口,C++ 调用需要反射,理论上,对效率有点影响。 OpenSL ES 可以做到 “拉” 方案,但 google 在官方文档说过对 OpenSL ES 接口没做太多的兼容,也许不太可靠。 ijkplayer 在 iOS 端也是两套方案:AudioUint 和 AudioQueue,由于本人对 iOS 开发不熟,不知道二者区别,因此不做展开。 在 Cocos Creator 音频播放改造中,在 Android 端选择了 google 最新推行的响应延迟极低的 Google Oboe 方案,Oboe 是 AAudio 和 OpenSL ES 封装集合,功能更强大,接口更人性化。在 iOS 端选择了 AudioQueue ,要问原因的话,就是 iOS AudioQueue 的接口和 Android Oboe 提供的接口更像… 音频播放模型,属于生产者消费者模型,音频设备在开启状态下,会不断拉取音频解码器生成的音频样本。 音频播放的接口并不复杂,主要用于替换 ffplay 程序中的 SDL 音频相关接口,具体接口代码如下: 1234567891011121314151617181920212223#ifndef I_AUDIO_DEVICE_H#define I_AUDIO_DEVICE_H#include \"AudioSpec.h\"class IAudioDevice {public: virtual ~IAudioDevice() {}; virtual bool open(AudioSpec *wantedSpec) = 0; // 开启音频设备,AudioSpec 结构体包含拉取回调 virtual void close() = 0; // 关闭音频输出 virtual void pause() = 0; // 暂停音频输出 virtual void resume() = 0; // 恢复音频输出 AudioSpec spec;};#endif //I_AUDIO_DEVICE_H 优化与扩展边下边播边下边播可以说是音视频播放器必备的功能,不但可以节省用户流量,而且可以提高二次打开速度。最常见的边下边播实现方式是在客户端建立代理服务器,只需要对播放器传入的资源路径加以修改,从而达到播放功能和下载功能解耦。不过理论上,建立代理服务器会增加移动设备的内存和电量消耗。 接下来介绍另外一种更简单易用的方案:利用 FFmpeg 提供的协议组合来实现边下边播 在查阅 FFmpeg 官方协议 文档时,发现某些协议支持组合使用,如下: cache:http://host/resource 这里在 http 协议前面添加了 cache 协议,即可以使用官方提供的播放过程中缓存观看过的一段,以便跳转使用,由于 cache 协议生成的文件路径问题,导致移动端不适用,此功能也达不到边下边播功能。 但从中可以得到结论:在其他协议前面加入自己协议,就能像钩子一样 hook 住其他协议接口,于是整理一个边下边播的 avcache 协议: 123456789const URLProtocol av_cache_protocol = { .name = \"avcache\", .url_open2 = av_cache_open, .url_read = av_cache_read, .url_seek = av_cache_seek, .url_close = av_cache_close, .priv_data_size = sizeof(AVContext), .priv_data_class = &av_cache_context_class,}; 原理就是:在 av_cache_read 方法中,调用其他协议的 read 方法,得到数据后,写入文件并存储下载信息,并把数据返回给播放器。 libyuv 替换 swscaleYUV(wikipedia),是一种颜色编码方法。为了节省带宽,大多数 YUV 格式平均使用的每像素位数都少于24位,因此一般视频都是用 YUV 颜色编码。YUV 由分为两种格式,分别是紧缩格式和平面格式。其中平面格式将 Y、U、V 的三个分量分别存放在不同的矩阵中。 根据上文,如果让片段着色器直接支持 YUV 纹理渲染,不同格式下,片段着色器所需要的 sampler2D 纹理采样器数量也不同,因此管理起来相当不便。最简单的方式,就是把 YUV 颜色编码转成 RGB24 颜色编码,因此需要用到 FFmpeg 提供的 swscale。 但在使用 swscale (已开启 FFmpeg 编译选项 neon 优化)进行颜色编码转换后,就可以发现 swscale 在移动端效率低下,使用小米 Mix 3 设备,1280x720 分辨率的视频,像素格式从 AV_PIX_FMT_YUV420P 转成 AV_PIX_FMT_RGB24,缩放按照二次线性采样,平均耗时高达 16 毫秒,而且导致 CPU 占用率相当高。数据截图待补: 经过 google 一番搜索,找到了 google 的 libyuv 替代方案 开源项目:https://chromium.googlesource.com/libyuv/libyuv/ 官方优化说明: Optimized for SSSE3/AVX2 on x86/x64; Optimized for Neon on Arm; Optimized for MSA on Mips。 使用 libyuv 进行像素格式转换后,使用小米 Mix 3 设备,1280x720 分辨率的视频,像素格式从 AV_PIX_FMT_YUV420P 转成 AV_PIX_FMT_RGB24,缩放按照二次线性采样,平均耗时 8 毫秒,相对 swscale 降低了一半。数据截图待补: Android asset 协议由于 Cocos Creator 本地音视频资源在 Android 端会打包到 asset 目录下,在 asset 目录下的资源需要使用 AssetManager 打开,因此需要支持 Android asset 协议,具体协议声明如下: 123456789const URLProtocol asset_protocol = { .name = \"asset\", .url_open2 = asset_open, .url_read = asset_read, .url_seek = asset_seek, .url_close = asset_close, .priv_data_size = sizeof(AssetContext), .priv_data_class = &asset_context_class,}; 成果展示 参考文档 FFmpeg: https://ffmpeg.org/ Cocos Creator 自定义 Assembler: https://docs.cocos.com/creator/manual/zh/advanced-topics/custom-render.html# Cocos Creator JSB 绑定:https://docs.cocos.com/creator/manual/zh/advanced-topics/JSB2.0-learning.html Android Oboe: https://github.com/google/oboe/blob/master/docs/FullGuide.md Google libyuv: https://chromium.googlesource.com/libyuv/libyuv/+/HEAD/docs/getting_started.md LearnOpenGL CN: https://learnopengl-cn.github.io/ WebGL: https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API/Tutorial/Animating_textures_in_WebGL ijkplayer: https://github.com/bilibili/ijkplayer 等等…","tags":[{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"}]},{"title":"让你的角色学会说话——rhubarb-lip-sync-ccc","date":"2020-12-08T17:33:00.000Z","path":"2020/12/08/rhubarb-lip-sync-ccc/","text":"lipsync rhubarb-lip-sync-ccc (简称 lipsync) 是一款专用于 Cocos Creator 的嘴型动画生成插件,它可以根据一段语音生成嘴型动画的 Animation Clip 。适合用于制作游戏角色的说话动画。支持中英文语言。 rhubarb-lip-sync-ccc 基于 DanielSWolf /rhubarb-lip-sync,在他的基础上添加了对自动生成 Cocos Creator 的 Animation Clip 的支持。 项目信息 插件商店:https://store.cocos.com/#/resources/detail/2572 源代码仓库:https://github.com/wzpan/rhubarb-lip-sync-ccc Demo demo 视频: 相关的示例仓库代码在:https://github.com/wzpan/lipsync-demo 使用方法 在 Cocos Creator 主菜单中,打开 【extensions】菜单 -> 【lipsync】-> 【launch】。开启 lipsync 的面板。 将嘴型图拖拽到对应的嘴型槽中,完成嘴型绑定。其中,6种嘴型(A, B, C, D, E, F)是必须的;而 G,H,X 嘴型则为可选。但推荐都提供,这样最终生成的结果会更为自然。 从本地选择一个录音文件。如果是非英文语言,建议取消勾选【是英文音频?】的选项。(这个选项将决定使用不同的识别器。对于英文录音,我们使用 PocketSphinx ;而对于其他语音的录音,我们使用 Phonetic。) 创建一个空的 Animation Clip ,并拖入目标 Animation Clip 槽位中。 完成以上四步之后,点击 【生成】 按钮,即开始生成动画。可以打开 Cocos Creator 的控制台面板,查看控制台日志: 123456789101112isEnglish: trueextendedShapes: GHX识别中...f5e3f311-875c-4fba-a235-173a82141735{"__type__":"cc.AnimationClip","_name":"happy_birthday","_objFlags":0,"_duration":1.32,"sample":100,"curveData":{"comps":{"cc.Sprite":{"spriteFrame":[{"frame":0,"value":{"__uuid__":"ac9ba9a0-efb8-4576-8d8a-6e9c4a178d2e"}},{"frame":0.04,"value":{"__uuid__":"a3aec0dc-fca3-4d4a-a93a-7e49a6f062a1"}},{"frame":0.22,"value":{"__uuid__":"d0778f9c-6403-42b6-9170-50783cdb777a"}},{"frame":0.3,"value":{"__uuid__":"e8474869-278a-4198-b6b9-3512bad62d54"}},{"frame":0.47,"value":{"__uuid__":"d0778f9c-6403-42b6-9170-50783cdb777a"}},{"frame":0.55,"value":{"__uuid__":"31364f93-705e-4cdb-b126-5acc5a5fd1c9"}},{"frame":0.79,"value":{"__uuid__":"dadaffea-9cc3-4082-b1b2-02d84200792b"}},{"frame":0.93,"value":{"__uuid__":"e8474869-278a-4198-b6b9-3512bad62d54"}},{"frame":1.21,"value":{"__uuid__":"ac9ba9a0-efb8-4576-8d8a-6e9c4a178d2e"}}]}}},"events":[],"speed":1,"wrapMode":1}生成成功! 如果出现 “生成成功!” 的消息,说明生成已经成功。此时可以将该 Animation Clip 拖动到嘴巴节点的 Animation 组件上,看看效果。 温馨提示:如果发现虽然动画生成成功,但是该动画没有任何帧,可以重启 Cocos Creator 看看问题是否解决。 常见问题 插件可以白嫖吗? 可以(您良心过意的去就好)。 支持 Windows 吗? 代码里是加入了 Windows 的支持。没实际测试过。欢迎当一把小白鼠。有问题跟我反馈就好。 支持 Mac M1 吗? Cocos Creator 好像也还不支持吧? 为毛要收费 1 块钱? 写插件不为了赚钱,只为交个朋友(我写插件不用花时间吗?1 块钱都不给我 555) 支持 3D 吗? 贴图的嘴巴是 ok 的。3D 的嘴型的话,建议直接用 rhubarb-lip-sync 生成每个时刻的嘴型数据,然后动态替换嘴型。 支持 Spine 动画吗? 可以移步 DanielSWolf/rhubarb-lip-sync (主要是因为穷买不起 Spine)。 计划支持 3.0 吗? 有计划。先上了 2.0 的商店看看大家反馈,如果有 bug ,修一波再说。 如何获取帮助目前只在 Mac 上测试过, Windows 上理论上也可以支持。 如果使用上遇到问题,请在 项目 仓库上提 issue 反馈。建议带上你的测试工程,方便我定位问题。","tags":[{"name":"Cocos Creator","slug":"Cocos-Creator","permalink":"http://oedx.github.io/tags/Cocos-Creator/"},{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"},{"name":"插件","slug":"插件","permalink":"http://oedx.github.io/tags/插件/"}]},{"title":"人生苦短,我们为 Cocos Creator 开发的插件和工具","date":"2020-06-30T16:31:28.000Z","path":"2020/06/30/cocos-creator-extensions/","text":"在使用 Cocos Creator 开发项目的过程中,为了提高开发效率我们开发了很多扩展插件,本文介绍常用的几款扩展,抛砖引玉,希望给大家带来帮助。 网页扩展:运行时查看场景节点树Cocos Creator 本地项目通常会在 Chrome 上调试运行,借助 Chrome 强大的开发者工具,我们可以对网页的性能、网络、脚本逻辑等进行调试优化。然而对于游戏来说,场景中的节点组件信息并没有办法直观的获取和修改,无法快速定位问题。为了解决这个痛点,我们可以修改 Cocos Creator 预览时的网页模版,让其显示更多的场景信息。 下面是修改过后的预览游戏界面 ccc-devtools 效果预览 扩展了如下功能: 场景节点树实时显示,节点、组件属性实时显示更改 可视化缓存资源 标记场景中节点位置 输出节点、组件引用到控制台 源码: https://github.com/potato47/ccc-devtools 此页面使用了 vue + vuetify 开发,对于单页面应用来说 vue 是非常好的选择,大家也可以基于这种方式来定制自己项目的预览界面。 VS Code 扩展:JavaScript 代码支持函数跳转Cocos Creator 支持 JavaScript 和 TypeScript 两种语言,如果你是用 VS Code 来开发 Cocos Creator 的 js 项目,那你的编程体验应该不是很好,因为 Cocos Creator 的组件脚本是一套自定义的结构, 12345678910111213141516171819const mylog = require('mylog');cc.Class({ properties: { node1: cc.Node, node2: cc.Node, label1: cc.Label, label2: cc.Label, }, start: function() { this.method1(); }, method1: function() { console.log('method1'); }, method2: function() { console.log('method2'); },}); 在这个结构下,VS Code 不能识别 this,当你在 start 方法里输入 this. 的时候无法准确的获得可以访问的属性和方法,也无法通过点击方法名或者模块名来跳转到定义位置。 好在 VS Code 有丰富的扩展 API,通过学习文档,我们开发出了一款让 js 代码支持函数跳转,属性提示的插件。 大家可以在 VS Code 插件商店里搜索 “Cocos Creator JS“ 来下载使用。 插件商店下载 下面是预览效果 js代码支持函数跳转 js代码提示 模块跳转 源码: https://github.com/potato47/vscode-cocos-creator-js 编辑器扩展:微信小游戏子包依赖检查得益于 Cocos Creator 优秀的跨平台能力,我们的项目上线了 Android、iOS、Web 和微信小游戏平台。由于微信平台对代码包大小有限制,在上线微信小游戏时我们使用了代码分包功能,但是项目开发过程中有些模块互相耦合,导致分包后主包与子包或者子包之间有依赖,在被依赖包加载前就对其进行导入会导致程序出错。为了解决这个问题我们开发了一款 Creator 插件,可以对子包依赖自动检测。效果如下: 子包依赖检查插件预览 点击检查依赖按钮后,插件会自动检查所有子包间的依赖,并可视化的显示出来,点击文件名定位到脚本位置,根据修改建议修改即可。 源码: https://github.com/potato47/wx-subpackage-helper 编辑器扩展:快速打开场景在使用 Cocos Creator 编辑器过程中个人体验最不好的就是资源管理器里的搜索功能了,在 1.x 版本搜索资源时每输入一个字母都会调用一次全局过滤资源的函数,输入过程中就会感觉到持续的卡顿,在 2.x 版本稍微优化了一下体验,改为敲回车再执行过滤函数,但在资源繁多的项目里搜索依然会卡顿。为了解决这个不大但影响心情的问题,我们开发了一款搜索插件,可以快速搜索打开场景或者预制体资源。无需等待,无需鼠标。 quick-open插件预览 源码: https://github.com/potato47/cocos-creator-quick-open-x 控制台扩展:控制台查看节点树,节点属性通过 Chrome 的开发者工具我们可以直接对原生平台中 Cocos Creator 的 JavaScript 代码进行远程调试,但一些 UI 相关问题依然不好定位,如果能在控制台里查看节点树就会方便很多。 首先介绍一下 console.group 和 console.groupEnd 这两个函数,它们可以组成一个可以折叠的标签,用于将输出信息分组,console.group 默认展开,相应的还有一个 console.groupCollapsed 默认折叠,显然我们可以用它们输出树形结构。 12345678910function tree(node = cc.director.getScene()) { let style = `color: ${node.parent === null || node.activeInHierarchy ? 'green' : 'grey'};`; if (node.childrenCount > 0) { console.groupCollapsed(`%c${node.name}`, style); node.children.forEach(child => tree(child)); console.groupEnd(); } else { console.log(`%c${node.name}`, style); }} 将上述代码粘贴到控制台,然后输入 tree(), 就可以查看当前场景的节点树结构了。 控制台查看节点树简版 上面的功能还很简陋,经过扩展我们可以获得更多的功能 控制台查看节点完整版 每个节点后边会附带常见的几个属性和唯一id,通过 cc.cat(id) 即可获得这个节点的引用,提高调试效率。 源码: https://github.com/potato47/ccc-devtools/blob/master/libs/js/cc-console-utils.js 适用于 Cocos 的 JSC 加解密工具Cocos Creator 在构建的时候支持对脚本进行加密和压缩。 Cocos Creator 脚本加密 然而,官方并没有提供一个解压和解密的工具。这给 jsc 的二次修改和重用带来了不便。 本工具弥补了这个不足:提供了与 Cocos Creator 相同的加密、解密、压缩、解压的方法。可以很方便地对构建得到的 jsc 进行解密、解压得到 js ,也可以将 js 压缩、加密回 jsc 。 源码: https://github.com/OEDx/cocos-jsc-endecryptor 参考 Cocos Creator 扩展编辑器文档 Cocos Creator 自定义网页预览文档 VS Code 插件开发文档","tags":[{"name":"Cocos Creator","slug":"Cocos-Creator","permalink":"http://oedx.github.io/tags/Cocos-Creator/"},{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"},{"name":"插件","slug":"插件","permalink":"http://oedx.github.io/tags/插件/"}]},{"title":"Cocos Creator 最佳实践:JavaScript兼容性问题规避","date":"2019-12-24T18:01:20.000Z","path":"2019/12/24/cocos-creator-api-compat/","text":"本文从 Cocos Creator 开发的角度出发,仔细探讨了关注 JavaScript API 兼容性的必要性,以及如何借助工具和 Polyfill 来规避 Cocos Creator 项目的兼容性问题。 一、引言:JavaScript虚拟机的差异性不同的浏览器和移动设备所使用的 JavaScript 虚拟机(VM)千差万别,所支持的 API 也大相径庭。 我们来了解一下 Cocos Creator 在各个端所使用的 JavaScript VM : 对于 iOS 客户端和 Mac 客户端:在 Cocos Creator 1.6 及以前,Cocos Creator 一直是使用非系统原生的 SpiderMonkey 作为 JS VM ;从 1.7 开始,Cocos Creator 引入了 JSB 2.0 ,支持了 V8、JavaScriptCore 等多种 JS VM 。于是 Cocos Creator 便将 iOS 端和 Mac 端的 JS VM 都改为了系统自带的 JavaScriptCore ,以达到节省包体的目的;到了 2.1.3 ,Cocos Creator 又将 Mac 端的 JS VM 切换到了 V8,以提升应用性能。 对于 Android 客户端和 Windows 客户端:在 Cocos Creator 1.6 及以前,Cocos Creator 同样是使用 SpiderMonkey 作为 JS VM ;从 1.7 开始,得益于 JSB 2.0 ,V8 成了 Android 和 Windows 客户端的 JS VM 。 对于 Web 端:使用浏览器本身的 JavaScript VM 来解析 JavaScript 代码。 JSB 2.0 架构图 从上可见,由于 JS VM 不同,同一份代码在不同的平台上运行可能会有很大差异。为了让我们的产品能够给尽可能多的用户使用,我们在开发阶段就需要时刻注意 JavaScript 的 API 兼容性。 举个例子:fetch() 方法是一个用来取代 XMLHTTPRequest 的 API。相比后者,它的优点在于可读性更高,且可以很方便地使用 Promise 写出更优雅的代码。 1234567fetch( 'http://domain/service', { method: 'GET' } ) .then( response => response.json() ) .then( json => console.log(json) ) .catch( error => console.error('error:', error) ); 然而,fetch() 方法不支持所有的 IE 浏览器,也无法在 2017 年以前的 Chrome、Firefox 和 Safari 版本上运行。当你的用户有很大一部分是上述的用户时,你就需要考虑禁止使用 fetch() API ,而重新回到 XMLHTTPRequest 的怀抱。 在开发阶段,人工保证 API 的兼容性是不可靠的。更可靠的方式是借助工具来自动化扫描。例如下面要介绍的 eslint-plugin-compat 。 二、使用 eslint-plugin-compateslint-plugin-compat 是 ESLint 的一个插件,由前 uber 工程师 Amila Welihinda 开发。它可以帮助发现代码中的不兼容 API 。 使用 eslint-plugin-compat 扫描不兼容 API 下面介绍如何在工程中接入 eslint-plugin-compat 。 2.1 安装 eslint-plugin-compat安装 eslint-plugin-compat 和安装其他 ESLint 插件类似: 1$ npm install eslint-plugin-compat --save-dev 还可以顺便把依赖的 browserslist 和 caniuse-lite 一起安装了: 1$ npm install browserslist caniuse-lite --save-dev 2.2 修改 ESLint 配置之后,我们需要修改 ESLint 的配置,加上该插件的使用: 1234567891011121314151617// .eslintrc.json{ \"extends\": \"eslint:recommended\", \"plugins\": [ \"compat\" ], \"rules\": { //... \"compat/compat\": 2 }, \"env\": { \"browser\": true // ... }, // ...} 2.3 配置目标运行环境通过在 package.json 中增加 browserslist 字段来配置目标运行环境。示例: 1234{ // ... \"browserslist\": [\"chrome 70\", \"last 1 versions\", \"not ie <= 8\"]} 上面的值表示 Chrome 版本 70 以上,或每种浏览器的最近一个版本,或者非 ie 8 及以下。这里的填写格式是遵循 browserslist (https://github.com/browserslist/browserslist )所定义的一套描述规范。browserslist 是一套描述产品目标运行环境的工具,它被广泛用在各种涉及浏览器/移动端的兼容性支持工具中,例如 eslint-plugin-compat 、babel、Autoprefixer 等。下面我们来详细了解一下 browserslist 的描述规范。 browserslist 支持指定目标浏览器类型,并且能够灵活组合多种指定条件。 指定目标浏览器类型browserslist 收录了如下一些浏览器,可以在条件中使用(注意大小写敏感): Android:用于 Android WebView。 Baidu:用于百度浏览器。 BlackBerry 或 bb:用于黑莓浏览器。 Chrome:用于 Google Chrome。 ChromeAndroid 或 and_chr:用于 Android Chrome。 Edge:用于 Microsoft Edge。 Electron:用于 Electron framework。 将会被转换成 Chrome 版本。 Explorer 或 ie:用于 Internet Explorer。 ExplorerMobile 或 ie_mob:用于 Internet Explorer Mobile。 Firefox 或 ff:用于 Mozilla Firefox。 FirefoxAndroid 或 and_ff:用于 Android Firefox。 iOS 或 ios_saf:用于 iOS Safari。 Node:用于 Node.js。 Opera:用于 Opera。 OperaMini 或 op_mini:用于 Opera Mini。 OperaMobile 或 op_mob:用于 Opera Mobile。 QQAndroid 或 and_qq:用于 Android QQ 浏览器。 Safari:用于 desktop Safari。 Samsung:用于 Samsung Internet。 UCAndroid 或 and_uc:用于 Android UC 浏览器。 kaios:用于 KaiOS 浏览器。 browseslist 的条件语法browserslist 支持非常灵活的条件语法,下面给出一些例子作为参考(注意大小写敏感),供读者们举一反三。 > 5%:表示要兼容全球用户统计比例 > 5% 的浏览器版本。>=、< 及 <= 也都是可用的。 > 5% in US:表示要兼容美国用户统计比例 > 5% 的浏览器版本。这里的 US 是美国的 Alpha-2 编码 [^1]。也可以换成其他国家/地区的 Alpha-2 编码。例如,中国就是 CN 。 > 5% in alt-AS:表示要兼容亚洲用户统计比例 > 5% 的浏览器版本。这里的 alt-AS 表示亚洲地区[^1]。 > 5% in my stats:表示要兼容自定义的用户统计比例 > 5% 的浏览器版本。 cover 99.5%:表示要兼容用户份额累计前 99.5% 的浏览器版本。 cover 99.5% in US:同上,但通过 Alpha-2 编码来加上国家/地区的限定。 cover 99.5% in my stats:使用用户的数据。 maintained node versions:所有官方还在维护的 Node.js 版本。 current node:Browserslist 现在正在使用的 Node.js 版本。 extends browserslist-config-mycompany:表示要兼容 browserslist-config-mycompany 这个 npm 包的查询结果。 ie 6-8:表示要兼容 IE 6 ~ IE 8 的版本(即 IE 6、IE 7 和 IE 8)。 Firefox > 20:表示要兼容 > 20 的 Firefox 版本。>=、< 及 <= 也都是可用的。 iOS 7:表示要兼容 iOS 7 。 Firefox ESR:表示要兼容最新的 Firefox ESR 版本。 PhantomJS 2.1 and PhantomJS 1.9:表示要兼容 PhantomJS 2.1 和 1.9 版本。 unreleased versions 或 unreleased Chrome versions:表示要兼容未发布的开发版本。后者则具体指明是要兼容未发布的 Chrome 版本。 last 2 major versions 或 last 2 iOS major versions:表示要兼容最近两个主要版本所包含的所有小版本。后者则具体指明是要兼容 iOS 的最近两个主要版本所包含的所有小版本。 since 2015 或 last 2 years:自 2015 年或最近两年到现在所发布的所有版本。 dead:官方不再维护或者超过两年没有更新的浏览器版本。 last 2 versions:每种浏览器的最近两个版本。 last 2 Chrome versions:Chrome 浏览器的最近两个版本。 defaults:Browserslist 的默认规则(> 0.5%, last 2 versions, Firefox ESR, not dead)。 not ie <= 8:从前面的条件中再排除掉低于或者等于 IE 8 的浏览器。 在阅读这些规则的时候,推荐访问 http://browsersl.ist 输入相同的命令进行测试,可以直接得出符合条件的浏览器版本。 在 browserl.ist 上测试条件 细心的读者可能会发现最后一条的查询结果会报错,这是因为 not 操作需要放在一个查询条件之后(下文会介绍)。你可以从其他规则中随意挑一条规则来组合,例如 ie 6-10, not ie <= 8 将会筛出 IE 9 和 IE 10 。 browseslist 的条件组合browserslist 支持多种条件的组合,下面我们来了解 browseslist 的条件组合方法。 , 和 or 都可以用来表示逻辑 “或”。例如,last 1 version or > 1% 与 last 1 version, > 1% 等价,都表示每种浏览器的最近 1 个版本,或者 > 1% 的市场份额。“或” 操作相当于集合论中的并集。 and 用来表示逻辑 “与”。例如,last 1 version and > 1% 表示每种浏览器的最近一个版本,且 > 1% 的市场份额。“与” 操作相当于集合论中的交集。 not 用来表示逻辑 “非”。例如 > .5% and not ie <= 8 表示 > 1% 的市场份额且排除 ie 8 及以下的版本。“非” 操作相当于集合论里头的补集,所以 not 不能作为第一个条件,因为你总需要知道“补”的是什么的“集”。 三种条件组合类型可以用下面的表格来示意: 条件组合类型 示意图 示例 or/, 组合 (并集) > .5% or last 2 versions > .5%, last 2 versions and 组合 (交集) > .5% and last 2 versions not 组合 (补集) > .5% and not last 2 versions > .5% or not last 2 versions > .5%, not last 2 versions 配置你的 browserslist了解了以上规则后,我们可以来配置适用于我们的工程的 browserslist 。 举个例子:假如我们的项目希望在 iOS 8 及以上,或者版本号 49 及以上且市场份额大于 0.2% 的 Chrome 桌面浏览器运行,那么可以使用如下的规则: 12345// ...\"browserslist\": [ \">.2% and chrome >= 49\", \"iOS >= 8\"], 完成后,可以使用 npx browserslist 来测试你配置的 browserslist 。 123456789101112131415161718192021$ npx browserslistchrome 78chrome 77chrome 76chrome 75chrome 74chrome 73chrome 72chrome 63chrome 49ios_saf 13.0-13.2ios_saf 12.2-12.4ios_saf 12.0-12.1ios_saf 11.3-11.4ios_saf 11.0-11.2ios_saf 10.3ios_saf 10.0-10.2ios_saf 9.3ios_saf 9.0-9.2ios_saf 8.1-8.4ios_saf 8 也可以访问 https://browsersl.ist/ 上输入条件测试结果。 测试效果完成了 browserslist 规则的配置后,我们就可以结合 ESLint 扫描工程中的 API 兼容问题。同时 VS Code 插件也可以即时提示不兼容的 API 调用。 在 VS Code 即时扫描不兼容 API 三、使用 eslint-plugin-builtin-compateslint-plugin-compat 的原理是针对确认的类型和属性,使用 caniuse (http://caniuse.com) 的数据集 caniuse-db 以及 MDN(https://developer.mozilla.org/en-US/ )的数据集 mdn-browser-compat-data 里的数据来确认 API 的兼容性。但对于不确定的实例对象,由于难以判断该实例的方法的兼容性,为了避免误报,eslint-plugin-compat 选择了跳过这类 API 的检查。 例如,foo.includes 在不确定 foo 是否为数组类型的时候,就无法判断 includes 方法的兼容性。在下图中,我们在使用上面的 browserslint 配置的情况下,includes 方法的兼容问题并没有被扫描出来: eslint-plugin-compat 无法检测实例对象的 API 然而,从 caniuse 上可以查知,Array.prototype.includes() 方法不能被 iOS 8 兼容: Array.prototype.includes() 并不能被 iOS 8 兼容 实际上,Cocos Creator 的 engine 项目自 2.1.3 版本开始,就已经针对 Array.prototype.includes() 方法加入了 Polyfill ,从而彻底规避了该 API 的兼容问题。在本节后面介绍 Polyfill 的时候我们将介绍如何避免该 API 的误报。 为了避免漏报这种问题,我们可以结合另一个兼容检查插件 eslint-plugin-builtin-compat 。该插件同样借助 mdn-browser-compat-data 来进行兼容扫描,与 eslint-plugin-compat 不同的是,该插件不会放过实例对象,因此它会把所有 foo.includes 的 includes 方法当成是 Array.prototype.includes() 方法来扫描。可想而知,这个插件可能会导致误报。因此建议将其告警级别改为 warning 级别。 3.1 安装 eslint-plugin-builtin-compat1$ npm install eslint-plugin-builtin-compat --save-dev 3.2 修改 ESLint 配置与 eslint-plugin-compat 类似,我们可以修改 ESLint 的配置,加上该插件的使用。但由于该插件容易误报,因此只建议将其告警级别改为 warning 级别: 12345678910111213141516171819// .eslintrc.json{ \"extends\": \"eslint:recommended\", \"plugins\": [ \"compat\", \"builtin-compat\" ], \"rules\": { //... \"compat/compat\": 2, \"builtin-compat/no-incompatible-builtins\": 1 }, \"env\": { \"browser\": true // ... }, // ...} 加入该插件后,可以发现 Array.prototype.includes() 方法将会被该插件告警: eslint-plugin-builtin-compat 可以检测实例对象的 API 四、使用 Polyfill 解决兼容问题靠 ESLint 在开发阶段扫描出 API 兼容问题固然是一种防治兼容性问题的手段,但如果团队里的同事并不认真注意 ESLint 的扫描结果,甚至没有将 ESLint 作为代码合入扫描的一环的话,就有可能会有漏网之鱼继续肆虐。 因此,一种更为一劳永逸的方法是为一些常用的 API 补上相应 Polyfill 。这样一方面可以为不兼容的浏览器版本添加上支持,另一方面又可以使得团队成员安心地使用新的 API ,提高开发效率。 4.1 Cocos Creator engine 里的 Polyfill实际上,Cocos Creator 的 engine 项目也内置了很多常见 API 的 Polyfill : cocos-creator/engine 项目内置的 Polyfill 其中就包括了 Array.prototype.includes() : Array.prototype.includes() 的 Polyfill 因此,如果使用 2.1.3 以上版本的 Cocos Creator 构建带有 Array.prototype.includes() 方法的工程,编译出来的应用将可以顺利在 iOS 8 机器上运行。这是因为 Array.prototype.includes() 在构建时被统一被 “翻译” 成了 engine 项目里提供的方法。 相应地,为了避免 Polyfill 里的 isArray 、find、includes 等 API 被 eslint-plugin-builtin-compat 误报,可以在 .eslintrc 中将这些 API 加入该插件的排除列表中: 12345678910111213// .eslintrc.json{ \"extends\": \"eslint:recommended\", \"plugins\": [ \"compat\", \"builtin-compat\" ], // ... \"settings\": { \"builtin-compat-ignore\": [\"ArrayBuffer\", \"find\", \"log2\", \"parseFloat\", \"parseInt\", \"assign\", \"values\", \"trimLeft\", \"startsWith\", \"endsWith\", \"repeat\"] } // ...} 4.2 自行增加 Polyfillengine 项目里的 Polyfill 并不能覆盖所有的 API 。如果你希望使用的某个不兼容 API 并没有包含在 engine 项目中,那么就得考虑给你自己的项目补上该 API 的 Polyfill 。 例如,string.prototype.padStart() 和 string.prototype.padEnd() 两个 API 分别提供了用于字符串的头部和尾部补全的便利方法: 1234'x'.padStart(5, 'ab') // 'ababx''x'.padStart(4, 'ab') // 'abax''x'.padEnd(5, 'ab') // 'xabab''x'.padEnd(4, 'ab') // 'xaba' 而这两个方法只在 iOS 10 及以上版本才被支持: string.prototype.padStart() 方法只在 iOS 10 以上才被支持 寻找 Polyfill如何寻找这两个方法的 Polyfill 呢?一个最权威的来源就是 MDN 站点(https://developer.mozilla.org/en-US/ )。以 string.prototype.padStart() 为例,我们可以在站点右上角的搜索框中输入 padStart : 在 MDN 中搜索 padStart 之后敲回车进入搜索,在搜索结果中点击最匹配的结果: padStart 搜索结果 就进入了 string-prototype-padStart 的文档页,在左侧的导航栏中可以看到有 Polyfill 的栏目: 文档页里的 Polyfill 栏目 点击它即可跳转到对应的 Polyfill 实现: string-prototype-padStart 的 Polyfill 编写自定义的 Polyfill 脚本找到了 string.prototype.padStart() 和 string.prototype.padEnd() 两个 API 的 Polyfill 后,我们在自己的工程中编写一个自定义的 Polyfill 脚本。例如叫做 ABCPolyfill.js : 123456789101112131415161718192021222324252627282930313233343536373839404142434445/** * ABCPolyfill.js * 补一些 polyfill,解决若干兼容问题 */var ABCPolyfill = function () { console.log('ABC polyfill'); if (!String.prototype.padStart) { String.prototype.padStart = function padStart(targetLength, padString) { targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0; padString = String(typeof padString !== 'undefined' ? padString : ' '); if (this.length >= targetLength) { return String(this); } else { targetLength = targetLength - this.length; if (targetLength > padString.length) { padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed } return padString.slice(0, targetLength) + String(this); } }; } if (!String.prototype.padEnd) { String.prototype.padEnd = function padEnd(targetLength,padString) { targetLength = targetLength>>0; //floor if number or convert non-number to 0; padString = String((typeof padString !== 'undefined' ? padString : ' ')); if (this.length > targetLength) { return String(this); } else { targetLength = targetLength-this.length; if (targetLength > padString.length) { padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed } return String(this) + padString.slice(0,targetLength); } }; }};module.exports.ABCPolyfill = ABCPolyfill; 接下来,我们要在应用启动后加载执行这个 Polyfill 脚本里的 ABCPolyfill() 方法,自动打上这两个 API 的 Polyfill 。我们可以再编写一个应用初始化脚本,例如叫做 ABCInit.js ,该脚本用于在应用初始化时执行一些指定工作。 1234567891011121314/** * ABCInit.js * 应用启动时的一些初始化工作 */import ABCPolyfill from 'ABCPolyfill';// 初始化操作function doInit() { ABCPolyfill.ABCPolyfill();}(function () { doInit();})(); 之后可以在你的工程的初始场景里脚本组件中引用该脚本即可生效: 1234567/** * 工程的初始场景挂载的脚本组件 */require('ABCInit');// ... 为了避免 eslint-plugin-builtin-compat 误报,可以将 padStart 和 padEnd 也追加进排除名单中: 12345678910111213// .eslintrc.json{ \"extends\": \"eslint:recommended\", \"plugins\": [ \"compat\", \"builtin-compat\" ], // ... \"settings\": { \"builtin-compat-ignore\": [\"ArrayBuffer\", \"find\", \"log2\", \"parseFloat\", \"parseInt\", \"assign\", \"values\", \"trimLeft\", \"startsWith\", \"endsWith\", \"repeat\", \"padStart\", \"padEnd\"] } // ...} 五、小结 时刻注意 API 兼容性; 使用 eslint-plugin-compat 检查静态类型的不兼容 API ,并将告警级别设为错误; 使用 eslint-plugin-builtin-compat 检查动态类型的不兼容 API,并将告警级别设为警告; 考虑为不兼容 API 增加 Polyfill 。 最后,本文是我们正在编写的书籍 《CocosCreator 最佳实践》 中的一篇,书籍正在加紧编写中,敬请期待。 脚注[^1]: 所有国家/地区的 Alpha-2 编码可以在这里查询:https://www.iban.com/country-codes 。所有的国家/地区/洲的编码也可以在 node_modules/caniuse-lite/data/regions 里找到。","tags":[{"name":"Cocos Creator","slug":"Cocos-Creator","permalink":"http://oedx.github.io/tags/Cocos-Creator/"}]},{"title":"小程序自动化测试实践","date":"2019-08-07T15:40:29.000Z","path":"2019/08/07/mini-program-auto-test/","text":"一、缘起-为什么要进行小程序自动化测试微信小程序生态日益完善,很多小程序项目页面越来越多,结构越来越复杂,业务逻辑也更加多样。以腾讯课堂小程序为例,目前腾讯课堂小程序部分页面结构和不同业务场景下的表现如下图所示: 腾讯课堂业务 可以看到在核心功能上主要页面对于不同业务场景有众多不同的表现,因此在开发与发布的过程中需要手动验证大量测试用例以保证小程序按预期表现运行,善于利用工具的程序员当然会想: 这种重复的工作能不能交给程序自动进行呢? web开发中对于这类测试问题已经有了很多自动化解决方案比如Selenium、Puppeteer,思路大体相同,都是让浏览器按照指定顺序自动在页面上完成点击、输入等操作,再将操作后的页面表现与想要得到的结果进行比较得到测试结论(断言)。那小程序中有没有一种方案能够按照这种思路实现自动化操作并提供页面信息用于断言呢?为了微信底层安全考虑,小程序环境一直比较封闭,留给开发者操作的余地很小,自动化操作基本无法实现,但5月出现了这样一个npm包,介绍了miniprogram-automator工具,给了小程序开发者希望。 二、缘遇-初试miniprogram-automator基于miniprogram-automator的文档描述简单总结一下,当通过命令打开开发版微信开发者工具的自动化接口并连接自动化接口后,此工具可提供以下能力: MiniProgram:获取小程序信息(页面堆栈、系统信息、页面内容),控制小程序(跳转页面、切换tab、调用方法) Page:获取页面信息(路径、元素、数据、结构),控制页面(设置渲染数据、调用方法) Element:获取元素信息(属性、样式、内容、位置),操控元素(点击、长按、调用方法) 所以小程序自动化控制的实现依赖于开发版小程序开发者工具以及miniprogram-automator工具。小程序开发者工具命令行用来打开指定自动化操作服务端口。(开发者工具版本需高于v1.02.1906042)。miniprogram-automator工具用来操作开发者工具中运行的小程序并获取所需的信息。对于测试需求可以结合jest框架进行测试用例的组织和断言。 不多废话,看完文档用一下: Ø 调用开发者工具命令行打开项目与指定自动化操作服务端口 1234567PS D:\\内测\\微信web开发者工具> ./cli.bat --auto D:\\weApp\\testMiniprogram --auto-port 9420Initializing...idePortFile: C:\\Users\\billcui\\AppData\\Local\\微信开发者工具\\User Data\\Default\\.idestarting ide...IDE server has started, listening on http://127.0.0.1:35510initialization finishedOpen project with automation enabled success D:\\weApp\\testMiniprogram 这一行命令需要注意的有: 文档要求开发者工具版本号必须高于v1.02.1906042,最好是最新的内测版工具,我是在v1.03.1906062运行成功的; 运行这行命令之前需要先打开开发者工具菜单中的设置->安全设置->服务端口; 自动化端口是独立于服务端口的(比如终端打印出的35510其实是服务端口),必须要看到Open project with automation enabled success D:\\weApp\\testMiniprogram这行提示才算是成功打开了自动化端口(9420)。 命令运行成功后,开发者工具会自动打开项目,并弹出提示 1561688637769 Ø 创建test.js,代码中引入miniprogram-automator工具,连接自动化操作端口 12345const automator = require('miniprogram-automator');const miniProgram = automator.connect({ wsEndpoint: 'ws://localhost:9420',}) Ø 利用miniprogram-automator提供的接口操作小程序从首页重启并进行相关操作 1234567891011121314const automator = require('miniprogram-automator');const miniProgram = automator.connect({ wsEndpoint: 'ws://localhost:9420',}).then(async miniProgram => { // 从首页重启 const page = await miniProgram.reLaunch('/pages/index/index'); // 从页面获取my-button组件 const button = await page.$('my-button'); // 打印出my-button的wxml信息 console.log(await button.wxml());}).catch(e => { console.log('catch a error', e);}); Ø 利用miniprogram-automator获取操作后页面相关信息,利用jest进行组织和断言 12345678910111213141516171819202122// index.spec.jsconst automator = require('miniprogram-automator');describe('课堂小程序自动化测试', () => { let miniProgram; // 运行测试前调用 beforeAll(async () => { miniProgram = await automator.connect({ wsEndpoint: 'ws://localhost:9420', }); }); // 运行测试后调用 afterAll(() => { miniProgram.disconnect(); }); // 测试内容 it('nohost检测', async () => { const page = await miniProgram.reLaunch('/pages/index/index'); const nohost = await page.$('nohost'); expect(nohost).toBeNull(); });}); 运行jest index.spec.js, 如果页面中不存在nohost组件则测试通过,结果如图所示: img 三、缘聚-自动化测试在课堂微信小程序中的应用腾讯课堂微信小程序引入自动化测试主要是为了解决开发、预发布环境、正式环境需要反复多次打开用例课程页面,操作繁琐,耗费大量人力的问题。针对课堂小程序checklist,尽可能利用自动化测试程序完成测试验证,减少手动操作,也可以避免人为检测的遗漏。 利用miniprogram-automator工具和jest框架,自动化测试主要能力为按照指定顺序模拟打开指定页面、点击、滚动等操作和设置page的data渲染数据,然后对特定的页面结构、数据、组件属性等信息进行断言,判断是否符合预期。 下面以腾讯课堂微信小程序的课程详情页为例来详细说明在实际项目中如何实现自动化测试: 课程详情页的UI主要分为视频部分,详情部分以及底部的购买按钮,未购买课程时表现主要有以下几种: 免费课程详情页、付费课程详情页 优惠券课程详情页、限时优惠课程详情页 假如对于 未购买的无优惠活动的付费课程详情页 的测试目标如下: 按钮应显示“立即购买”,点击购买按钮可跳转到支付页 点击试学按钮可正常播放试学视频 未购买课程时点击课程视频无法播放 实现这个测试,在x.spec.js文件中首先需要要按照上文的步骤引入miniprogram-automator,在beforeAll中连接已经打开自动化端口的微信小程序项目。(这里不再重复代码,见上一章)下面直接看测试内容的代码。 按钮显示和点击跳转支付页测试 12345678910111213141516// 打开页面,通过url传参const page = await miniProgram.reLaunch(`/pages/course/course?cid=${commonPayCid}`);// 获取按钮组件信息const basicApplyButton = await page.$('.basic--buy');// 判断按钮显示内容expect(await basicApplyButton.wxml()).toContain('立即购买'); // 模拟点击按钮await basicApplyButton.tap();// 等待页面跳转await page.waitFor(1500);// 获取当前页面路径const currentPage = await miniProgram.currentPage();// 判断跳转后路径是否正确expect(currentPage.path).toContain('pages/order/order');// 跳转回来await miniProgram.navigateBack(); 目前miniprogram-automator提供了两种方法获取到页面中的组件:page.$ 和 page.$$ 。经过实验发现两者的selector支持通过组件名和类名选择组件,但对于自定义组件内部的结构,就不能直接这样拿到了。 课程详情页的底部按钮其实是一个自定义组件,并且还嵌套了子自定义组件,我们看一下底部按钮的wxml结构: 红色框框就是想要获取的目标,尝试一下直接通过 page.$('.bottom-btn') 或 page.$('.buy') 返回的都是undefined,那怎么获取呢?我们先来看看botton-button内部是什么样子的。 12const basicApplyButton = await page.$('bottom-button');console.log(await basicApplyButton.wxml()); 获取bottom-button并打印它的wxml字符串看一下: 1\"<view class=\"bottom-button--bottom-button-space\" wx:nodeid=\"17\"><view class=\"bottom-button--bottom-button-wrapper\" wx:nodeid=\"261\"><basic is=\"components/discount-button/components/basic/basic\" wx:nodeid=\"262\"><view wx:nodeid=\"263\"><view class=\"basic--bottom-button-container\" wx:nodeid=\"264\"><view class=\"basic--bottom-btn basic--buy\" wx:nodeid=\"265\"> 立即购买 </view></view></view></basic></view></view>\" 发现了什么!小程序实际运行时,自定义组件内部的类名都加上了组件名前缀,再试试 page.$('.basic--buy') 发现果然成功获取到了,所以虽然表面上miniprogram-automator只能操作和获取page中的内容,但自定义组件内部的结构实际上也是以某种方式存在于page中的。 接下来看一下跳转,可以直接获取到对应组件后调用.tap()方法来模拟点击,这里需要注意的是,由于微信小程序开发者工具中点击打开新页面耗时较长,需要等待页面加载一会,不然接下来获取当前页面路径的时候页面还没跳转过去就拿不到不到新页面路径了。等待的时长可以根据经验给个稍大的比较安全的值。 点击试学按钮可正常播放试学视频 12const player_video = await tapTcplayer(page, '.player-task');expect(await player_video.wxml()).toContain('video-current-time'); // 试学 由于微信开发者工具的限制,云点播会降级为tcplayer播放,tcplayer内部的核心组件其实是 <video> 组件,wxml结构如下: 如何判断视频是否成功播放呢? 我们先按照上面的方法获取播放成功的video组件的wxml字符串看看 1\"<video class=\"component-video-video--player_video\" controls=\"\" danmu-list=\"[]\" initial-time=\"0\" object-fit=\"contain\" poster=\"https://10.url.cn/qqc...\" src=\"http://113.96.98.148/vedu.tc.qq.com/AtmkzyWCuq...\" autoplay=\"\" wx:nodeid=\"446\"><div class=\"video-container\" wx:nodeid=\"447\"><div class=\"video-bar full\" style=\"opacity: 1;\" wx:nodeid=\"457\"><div class=\"video-controls\" wx:nodeid=\"458\"><div class=\"video-control-button pause\" wx:nodeid=\"459\"><div parse-text-content=\"\" class=\"video-current-time\" wx:nodeid=\"460\">00:02<div class=\"video-progress-container\" wx:nodeid=\"462\"><div class=\"video-progress\" wx:nodeid=\"463\"><div style=\"left: -21px;\" class=\"video-ball\" wx:nodeid=\"464\"><div class=\"video-inner\" wx:nodeid=\"465\"><div parse-text-content=\"\" class=\"video-duration\" wx:nodeid=\"466\">06:09<div class=\"video-fullscreen\" wx:nodeid=\"468\"><div style=\"z-index: -9999\" class=\"video-danmu\" wx:nodeid=\"453\"></video>\" 惊了!原生 <video> 组件内部竟然是 <div> ,我们还可以注意到一个关键的class: video-current-time 内部数值为00:02,这不是当前播放进度吗?刚好可以用来判断视频有没有播放成功,就是它了! 对比发现播放失败时根本不会出现class为video-current-time的div,所以直接用是否包含video-current-time来判断了。 未购买课程时点击课程视频无法播放 点击非试看课程时,无法播放视频。由于不播放视频时页面中只显示cover封面图,不attatch<video>组件,所以直接用获取视频组件的结果进行 toBeNull() 判断即可。结合上面所有的代码如下: 12345678910111213141516171819202122async function tapTcplayer(page, className = '.task-item') { const taskItem = await page.$(className); await taskItem.tap(); await page.waitFor(3000); const playercover = await page.$('.player-cover'); const player_video = await playercover.$('.component-video-video--player_video'); return player_video; } it('付费课程详情页按钮显示、跳转、点播、试学功能测试', async () => { const page = await miniProgram.reLaunch(`/pages/course/course?cid=${commonPayCid}`); const basicApplyButton = await page.$('.basic--buy'); expect(await basicApplyButton.wxml()).toContain('立即购买'); // 按钮显示 await basicApplyButton.tap(); await page.waitFor(1500); const currentPage = await miniProgram.currentPage(); expect(currentPage.path).toContain('pages/order/order'); await miniProgram.navigateBack(); const player_video = await tapTcplayer(page); expect(player_video).toBeNull(); // 未报名不能播放视频 const player_video_new = await tapTcplayer(page, '.player-task'); expect(await player_video_new.wxml()).toContain('current'); // 试学 }, 20000); 可以看到实际上先测试了播放课程功能,再测试了试学功能,这是为什么呢? 这是一个坑:由于播放课程失败时会有showModel弹窗提示,这个弹窗是不在wxml结构中的,无法用自动化控制工具点击关闭,实际测试中这个弹窗会阻塞下一个测试项的第一步:页面跳转,导致下一个测试项直接打不开页面导致失败,只能等待一段时间再跳转,所以直接把弹窗放在测试试学功能之前,就不会影响下一个测试项了。 还有一个需要注意的地方,在项目中,点击播放后5秒不触发进度刷新的方法就会上报视频播放失败,实际测试发现一般3秒即可正常播放,所以只等待3秒,3秒后未成功播放的视为播放失败。 最后,jest默认一个测试项的时长不能大于5秒,这项测试既有页面跳转又有视频播放,明显会超出5秒的限制,实际耗时约为15秒左右,所以修改时长限制为20000毫秒。 运行测试脚本结果如下: img 目前实现的测试功能如下: nohost检测 首页数据拉取、显示、跳转测试 付费课程详情页按钮显示、跳转、点播、试学功能测试 优惠券按钮显示、领取功能测试 限时优惠按钮显示测试 免费课程详情页按钮显示、报名、点播功能测试 分类页展示、跳转列表页、跳转详情页测试 Checklist中功能测试的完成情况如下:完成度为65% review点 自动化测试 备注 是否去除nohost插件 支持 首页是否正常显示 支持 pc首页小程序登陆是否正常 暂不 信息授权无法自动完成 安卓支付能力是否正常 暂不 webview内部无法获取信息 分类页是否正常显示 支持 是否可以正常登陆 暂不 信息授权无法自动完成 课程表是否正常展示,学习进度/直播状态是否正常显示 支持 待完善 课程详情页是否可以正常展示 支持 扫码/分享是否正常唤起小程序 暂不 开发者工具不支持 付费课直播是否可以正常播放(上云跟腾讯视频) 暂不 开发者工具不支持直播 免费课直播是否可以正常播放(上云跟腾讯视频) 暂不 开发者工具不支持直播 免费课录播是否可以正常播放(上云跟腾讯视频) 部分支持 开发者工具降级到tcplayer 付费课录播是否可以正常播放(上云跟腾讯视频) 部分支持 开发者工具降级到tcplayer 试学任务是否可以正常播放 支持 详情页视频是否正常播放 支持 营销工具相关显示是否正常 支持 是否能正常完成支付逻辑 暂不 webview内部无法获取信息 类目筛选是否正常 支持 待完善 是否可以正常搜索且列表显示正常 支持 待完善 本地加载耗时是否保持1s内 支持 四、缘续-遇到的问题与功能限制 获取页面中的组件只能采用 page.$() 或 page.$$() 方法,经尝试选择器仅支持组件名和类名。无法直接获取自定义组件内部组件元素,需要在类名前增加前缀。实际项目的页面中大量使用自定义组件,对于自定义组件内部的结构判断非常不方便,只能通过wxml()方法将自定义组件内部结构打印出来才能确认内部的子组件的实际情况。且无法调用自定义组件内部的方法。 Jest的snapshot功能对于结构相对固定的组件或页面是一种非常好的测试方式,但用起来有坑。在小程序中snapshot的对照内容通常是通过组件的wxml方法打印的字符串,但实际在运行时,wxml方法返回结果可能会不同,组件可能会被自动添加上wx:node-id属性,但有时返回字符串中又不添加,会导致snapshot测试不通过。 目前只能在开发者工具环境下测试,导致直播功能无法测试且云点播会自动降级为腾讯视频点播,直播也无法测试。 登陆、扫码等功能无法测试,因为自动化控制工具无法扫描和点击授权弹窗。 <web-view> 组件获取不到任何内部信息,也无法自动化控制。 希望这些问题后续能够得到解决~~","tags":[{"name":"小程序","slug":"小程序","permalink":"http://oedx.github.io/tags/小程序/"},{"name":"自动化","slug":"自动化","permalink":"http://oedx.github.io/tags/自动化/"}]},{"title":"【Cocos Creator】Cocos Creater 中如何实现JSB的自动绑定","date":"2019-07-03T21:00:00.000Z","path":"2019/07/03/cocos-creator-js-binding-auto/","text":"尽管Cocos官方提供了jsb.reflection.callStaticMethod方式支持从JS端直接调用Native端(Android,iOS/Mac)的接口,但是经过大量实践发现此接口在大量频繁调用情况下性能很低下,尤其是在Android端,比如调用Native端实现的打印log的接口,而且会容易引起一些native crash,例如local reference table overflow等问题。纵观Cocos原生代码的实现,基本所有的接口方法的实现都是基于JSB的方式来实现,所以此文主要讲解下JSB的自动绑定逻辑,帮助大家能快速实现callStaticMethod到JSB的改造过程。 背景对于用过Cocos Creater(为了方便后文直接简称CC)的人来说,jsb.reflection.callStaticMethod 这个方法肯定不陌生,其提供了我们从JS端调用Native端的能力,例如我们要调用Native实现的log打印和持久化的接口,就可以很方便的在JavaScrpit中按照如下的操作调用即可:123456789if (cc.sys.isNative && cc.sys.os == cc.sys.OS_IOS) { msg = this.buffer_string + '\\n[cclog][' + clock + '][' + tag + ']' + msg; jsb.reflection.callStaticMethod(\"ABCLogServuce\", \"log:module:level:\", msg, 'cclog', level); return;} else if (cc.sys.isNative && cc.sys.os == cc.sys.OS_ANDROID) { msg = this.buffer_string + '\\n[cclog][' + clock + '][' + tag + ']' + msg; jsb.reflection.callStaticMethod(\"com/example/test/CommonUtils\", \"log\", \"(ILjava/lang/String;Ljava/lang/String;)V\", level, 'cclog', msg); return;} 尽管使用很简单,一行代码就能实现跨平台调用,稍微看下其实现就可以知道,在C++层Android端是通过jni的方式实现的,IOS端是通过运行时的方式动态调用,但是为了兼顾通用性和支持所有的方法,Android端没有对jni相关对象做缓存机制,就会导致短时间大量调用时出现很严重的性能问题,之前我们遇到的比较多的情况就是在下载器中打印log,某些应用场景短时间内触发大量的下载操作,就会出现local reference table overflow的crash,甚至在低端机上导致界面卡顿无法加载出来的问题。修复此问题就需要针对log调用进行JSB的改造,同时还要加上jni的相关缓存机制,优化性能。jSB绑定说白了就是 C++ 和脚本层之间进行对象的转换,并转发脚本层函数调用到 C++ 层的过程JSB绑定通常有手动绑定和自动绑定两种方式,手动绑定方式可以参考同事写的这篇文章,手动绑定方式优点是灵活,可定制型强,缺点就是全部代码要自己书写,尤其是在js类型跟c++类型转换上,稍有不慎容易导致内存泄漏,某些指针或者对象没有释放。自动绑定方式则会帮你省了很多麻烦,直接通过一个脚本一键生成相关的代码,后续如果有新增或者改动,也只需要重新执行一次脚本即可。所以自动绑定对于不需要进行强定制,需要快速完成JSB的情况来说就再适合不过了。下面就一步步说明下如何实现自动绑定JSB: 环境配置和自动绑定展示环境配置自动绑定,说简单点,其实就只要执行一个python脚本即可自动生成对应的.cpp,.h ,.js文件。所以首先要保证电脑有python运行环境,这里以Mac上安装为例来讲解1.安装python,强烈建议先安装HomeBrew,然后直接命令行运行12/usr/bin/ruby -e \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)\"brew install python 2.通过pip安装python的一些依赖库123sudo easy_install pipsudo pip install PyYAMLsudo pip install Cheetah 3.安装NDK,涉及到c++肯定这个是必不可少的,建议安装14b版本,然后在~/.bash_profile 中设置 PYTHON_ROOT 和 NDK_ROOT 这两个环境变量,因为在后面执行的python文件里面就会直接用到这两个环境变量12export NDK_ROOT=\"/Users/mycount/Library/Android/sdk/ndk-r14b\"export PYTHON_BIN=\"/usr/bin/python\" Window下直接参考上面需要安装的模块直接安装就好了,最后也要记得配置环境变量。 自动绑定展示这里演示的是cocos引擎下面也即build/jsb-default/frameworks/cocos2d-x/cocos/scripting/js-bindings/auto目录下的文件(如下图所示)是怎么自动生成的, 其实从这些文件名的开头也能看出,这些文件命名都是有某些特定规律的,那么这些文件是怎么生成的呢?首先打开终端,先cd到build/jsb-default/frameworks/cocos2d-x/tools/tojs目录下,然后直接运行./genbindings.py 大概运行一分钟左右后,会出现如下的提示,说明已经顺利生成完了。 当然一般大家第一次运行后很可能会失败,出现如下面类似的报错 一般都是因为配置的NDK版本太高导致,最开始我是用NDK16b就出现了问题,换成NDK14b后就OK了。 经过上面的步骤后,build/jsb-default/frameworks/cocos2d-x/cocos/scripting/js-bindings/auto下的文件就全部自动生成出来了,是不是非常方便。下面再以js层通过jsb调用Native层的log方法打印日志为例,详细的告知下如何实现通过自动绑定工具,依据自己写的c++代码,生成对应的自动绑定文件。 编写c++层的实现C++作为连接js层和Native层的桥梁,既然要实现jsb调用,那第一步肯定是要先把C++层的头文件和实现准备好,这里我们在build/jsb-defaul/frameworks/cocos2d-x/cocos创建一个test文件夹用于存放相关文件, 这里先准备ABCJSBBridge.h,里面主要是申明了一个abcLog的函数,此函数就是供JS层调用打log的,另外由于打log方法肯定在js层很多地方都会使用,所以这里采用了一个单例模式,提供了getInstance()来获取当前类的实例12345678910111213141516171819202122232425262728293031#include <string>#include \"base/CCConsole.h\"#ifndef PROJ_ANDROID_STUDIO_ABCJSBBRIDGE_H#define PROJ_ANDROID_STUDIO_ABCJSBBRIDGE_H#define DLLOG(format, ...) cocos2d::log(format, ##__VA_ARGS__)#endif //PROJ_ANDROID_STUDIO_ABCJSBBRIDGE_Hnamespace abc{ class IABCJSBBridgeImpl; class JSBBridge { public: void abcLog(const int level, const std::string& tag, const std::string& msg); /** * Returns a shared instance of the director. * @js _getInstance */ static JSBBridge* getInstance(); /** @private */ JSBBridge(); /** @private */ ~JSBBridge(); bool init(); private: std::unique_ptr<IABCJSBBridgeImpl> _impl; };} 下面是对应的实现ABCJSBBridge.cpp:12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455#include \"ABCJSBBridge.h\"// include platform specific implement class#if (CC_TARGET_PLATFORM == CC_PLATFORM_MAC || CC_TARGET_PLATFORM == CC_PLATFORM_IOS)#include \"ABCJSBBridge-apple.h\"#define JSBBridgeImpl JSBBridgeApple#elif (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)#include \"ABCJSBBridge-android.h\"#define JSBBridgeImpl JSBBridgeAndroid#endifnamespace abc{ // singleton stuff static JSBBridge *s_SharedJSBBridge = nullptr; JSBBridge::JSBBridge() { DLLOG(\"Construct JSBBridge %p\", this); init(); } JSBBridge::~JSBBridge() { DLLOG(\"Destruct JSBBridge %p\", this); s_SharedJSBBridge = nullptr; } JSBBridge* JSBBridge::getInstance() { if (!s_SharedJSBBridge) { DLLOG(\"getInstance JSBBridge \"); s_SharedJSBBridge = new (std::nothrow) JSBBridge(); CCASSERT(s_SharedJSBBridge, \"FATAL: Not enough memory for create JSBBridge\"); } return s_SharedJSBBridge; } bool JSBBridge::init(void) { DLLOG(\"init JSBBridge \"); _impl.reset(new JSBBridgeImpl()); } void JSBBridge::abcLog(const int level, const std::string& tag, const std::string& msg) { _impl->abcLog(level, tag, msg); }} CCIABCJSBBridgeIml.h:1234567891011121314151617181920#include <string>#include \"base/CCConsole.h\"#ifndef PROJ_ANDROID_STUDIO_CCIABCJSBBRIDGEIML_H#define PROJ_ANDROID_STUDIO_CCIABCJSBBRIDGEIML_H#endif //PROJ_ANDROID_STUDIO_CCIABCJSBBRIDGEIML_H#define DLLOG(format, ...) cocos2d::log(format, ##__VA_ARGS__)namespace abc{ class IABCJSBBridgeImpl { public: virtual ~IABCJSBBridgeImpl(){} virtual void abcLog(const int level, const std::string& tag, const std::string& msg) = 0; };} 这里为了方便区分Android平台和iOS平台的实现,仿照Cocos源码其他地方的写法,分别提供了ABCJSBBridge-android.h和ABCJSBBridge-apple.h以及对应的实现类,两个平台分别继承IABCJSBBridgeImpl然后实现内部的虚函数即可。 JSB配置脚本编写为了保持跟官方的一致,我们在build/jsb-default/frameworks/cocos2d-x/tools/tojs 目录下创建genbindings_test.py,里面的内容基本跟genbindings.py 差不多,主要区别有如下几点:1.去掉了cmd_args 那段,里面主要是记录了cocos自带的一些需要生成jsb的文件,因为考虑到项目可能会对Cocos源码进行修改,如果这时候把这部分保留的话,当运行脚本后会把我们自带的修改就给覆盖掉了2.取消了定制的output_dir也就是最终生成的js,c++等绑定文件的路径,而是保持跟Cocos一样,也即在cocos/scripting/js-bindings/auto,主要为了方便下一步配置mk文件 这里先说下genbindings_test.py里面配置的一些参数:1.NDK_ROOT 环境变量:指示 NDK 的根目录2.PYTHON_BIN 环境变量:指示 Python 命令的路径3.cocosdir:Cocos 引擎根目录,在用户工程下一般是 build/jsb-default/frameworks/cocos2d-x/4.jsbdir:JSB 目录,在用户工程下一般是 build/jsb-default/frameworks/cocos2d-x/cocos/scripting/js-bindings5.cxx_generator_root:自动绑定工具路径,在用户工程下一般是 build/jsb-default/frameworks/cocos2d-x/tools/bindings-generator6.output_dir:生成的绑定文件存储路径7.cmd_args和custom_cmd_args:所有配置文件,及其对应的模块名称和输出文件名称 这里自动绑定工具使用 libclang 的 python API 对 C++ 头文件进行语法分析。绑定的过程大致如下: 创建绑定代码输出文件。 递归扫描需要绑定的头文件。 通过 libclang 的 clang.cindex python 模块找到所有需要绑定的类,公共 API 等。 按照模版生成类绑定函数,API 绑定函数,绑定注册函数并输出到文件中。 关于custom_cmd_args参数的配置这里说明下: 12'cocos2dx_test.ini': ('cocos2dx_test', 'jsb_cocos2dx_test_auto'),配置文件:(模块名称,输出的绑定文件名) 这里的配置文件cocos2dx_test.ini又是用来干嘛的呢?其实就跟build/jsb-default/frameworks/cocos2d-x/tools/tojs/下的其他.ini文件类似,主要让自动绑定工具知道哪些 API 要被绑定和以什么样的方式绑定,写法上直接参考Cocos已有的ini文件,这里展示下cocos2dx_test.ini的内容: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657[cocos2dx_test]# the prefix to be added to the generated functions. You might or might not use this in your own# templatesprefix = cocos2dx_test# create a target namespace (in javascript, this would create some code like the equiv. to `ns = ns || {}`)# all classes will be embedded in that namespacetarget_namespace = abcandroid_headers = -I%(androidndkdir)s/platforms/android-14/arch-arm/usr/include -I%(androidndkdir)s/sources/cxx-stl/gnu-libstdc++/4.8/libs/armeabi-v7a/include -I%(androidndkdir)s/sources/cxx-stl/gnu-libstdc++/4.8/include -I%(androidndkdir)s/sources/cxx-stl/gnu-libstdc++/4.9/libs/armeabi-v7a/include -I%(androidndkdir)s/sources/cxx-stl/gnu-libstdc++/4.9/includeandroid_flags = -D_SIZE_T_DEFINED_ clang_headers = -I%(clangllvmdir)s/%(clang_include)s clang_flags = -nostdinc -x c++ -std=c++11 -U __SSE__cocos_headers = -I%(cocosdir)s -I%(cocosdir)s/cocos -I%(cocosdir)s/cocos/platform/android -I%(cocosdir)s/externalcocos_flags = -DANDROIDcxxgenerator_headers = # extra arguments for clangextra_arguments = %(android_headers)s %(clang_headers)s %(cxxgenerator_headers)s %(cocos_headers)s %(android_flags)s %(clang_flags)s %(cocos_flags)s %(extra_flags)s # what headers to parseheaders = %(cocosdir)s/cocos/test/ABCJSBBridge.h# cpp_headers = network/js_network_manual.h# what classes to produce code for. You can use regular expressions here. When testing the regular# expression, it will be enclosed in \"^$\", like this: \"^Menu*$\".classes = JSBBridge# what should we skip? in the format ClassName::[function function]# ClassName is a regular expression, but will be used like this: \"^ClassName$\" functions are also# regular expressions, they will not be surrounded by \"^$\". If you want to skip a whole class, just# add a single \"*\" as functions. See bellow for several examples. A special class name is \"*\", which# will apply to all class names. This is a convenience wildcard to be able to skip similar named# functions from all classes.skip = JSBBridge::[init]rename_functions = rename_classes =# for all class names, should we remove something when registering in the target VM?remove_prefix = # classes for which there will be no \"parent\" lookupclasses_have_no_parents = JSBBridge# base classes which will be skipped when their sub-classes found them.base_classes_to_skip = Clonable# classes that create no constructor# Set is special and we will use a hand-written constructorabstract_classes = JSBBridge 其实从里面的注释也讲的非常详细,这里说几个主要的属性及含义: 以上的配置完成后,就可以按照如下命令运行自动生成绑定文件: 然后就会看到在build/jsb-default/frameworks/cocos2d-x/cocos/scripting/js-bindings下面多出了三个绑定文件 打开生成的jsb_cocos2dx_test_autp.cpp:123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778#include \"scripting/js-bindings/auto/jsb_cocos2dx_test_auto.hpp\"#include \"scripting/js-bindings/manual/jsb_conversions.hpp\"#include \"scripting/js-bindings/manual/jsb_global.h\"#include \"test/ABCJSBBridge.h\"se::Object* __jsb_abc_JSBBridge_proto = nullptr;se::Class* __jsb_abc_JSBBridge_class = nullptr;static bool js_cocos2dx_test_JSBBridge_abcLog(se::State& s){ abc::JSBBridge* cobj = (abc::JSBBridge*)s.nativeThisObject(); SE_PRECONDITION2(cobj, false, \"js_cocos2dx_test_JSBBridge_abcLog : Invalid Native Object\"); const auto& args = s.args(); size_t argc = args.size(); CC_UNUSED bool ok = true; if (argc == 3) { int arg0 = 0; std::string arg1; std::string arg2; ok &= seval_to_int32(args[0], (int32_t*)&arg0); ok &= seval_to_std_string(args[1], &arg1); ok &= seval_to_std_string(args[2], &arg2); SE_PRECONDITION2(ok, false, \"js_cocos2dx_test_JSBBridge_abcLog : Error processing arguments\"); cobj->abcLog(arg0, arg1, arg2); return true; } SE_REPORT_ERROR(\"wrong number of arguments: %d, was expecting %d\", (int)argc, 3); return false;}SE_BIND_FUNC(js_cocos2dx_test_JSBBridge_abcLog)static bool js_cocos2dx_test_JSBBridge_getInstance(se::State& s){ const auto& args = s.args(); size_t argc = args.size(); CC_UNUSED bool ok = true; if (argc == 0) { abc::JSBBridge* result = abc::JSBBridge::getInstance(); ok &= native_ptr_to_seval<abc::JSBBridge>((abc::JSBBridge*)result, &s.rval()); SE_PRECONDITION2(ok, false, \"js_cocos2dx_test_JSBBridge_getInstance : Error processing arguments\"); return true; } SE_REPORT_ERROR(\"wrong number of arguments: %d, was expecting %d\", (int)argc, 0); return false;}SE_BIND_FUNC(js_cocos2dx_test_JSBBridge_getInstance)bool js_register_cocos2dx_test_JSBBridge(se::Object* obj){ auto cls = se::Class::create(\"JSBBridge\", obj, nullptr, nullptr); cls->defineFunction(\"abcLog\", _SE(js_cocos2dx_test_JSBBridge_abcLog)); cls->defineStaticFunction(\"getInstance\", _SE(js_cocos2dx_test_JSBBridge_getInstance)); cls->install(); JSBClassType::registerClass<abc::JSBBridge>(cls); __jsb_abc_JSBBridge_proto = cls->getProto(); __jsb_abc_JSBBridge_class = cls; se::ScriptEngine::getInstance()->clearException(); return true;}bool register_all_cocos2dx_test(se::Object* obj){ // Get the ns se::Value nsVal; if (!obj->getProperty(\"abc\", &nsVal)) { se::HandleObject jsobj(se::Object::createPlainObject()); nsVal.setObject(jsobj); obj->setProperty(\"abc\", nsVal); } se::Object* ns = nsVal.toObject(); js_register_cocos2dx_test_JSBBridge(ns); return true;} 看到这里是不是感觉很熟悉,跟Cocos 已有的那些cpp完全一样,甚至包括里面的注册函数和类的定义都给全部自动生成了。 Cocos编译配置尽管经过上面一步后我们已经生成出来了绑定文件,但是js层还是没法直接使用,因为还需要把生成的绑定文件,配置到mk文件中,从而跟其他c++文件一起编译才行,这部分主要就是将最后的mk编译配置。1.打开build/jsb-default/frameworks/cocos2d-x/cocos/Android.mk文件,在其中加上最开始实现的cpp文件 2.打开build/jsb-default/frameworks/cocos2d-x/cocos/scripting/js-bindings/proj.android/Android.mk,在其中加上上一步生成的cpp文件 3.打开build/jsb-default/frameworks/runtime-src/Classes/jsb_module_register.cpp,添加引擎启动时调用绑定文件的注册函数,从而将其添加到js环境中 4.打开build/jsb-default/frameworks/cocos2d-x/cocos/scripting/js-bindings/script/jsb_boot.js,在其中增加js对象的初始化 上面说到的jsb_module_register.cpp 和jsb_boot.js 其实都是在Cocos引擎初始化的时候就会去调用的,关于启动流程感兴趣的可以去看看这篇文章 经过上面这些配置后,最终就可以在js层直接像下面这样来进行调用,而不是用callStaticMethod方式 自动绑定的限制条件自动绑定依赖于Bindings Generator 工具,Cocos官方还在github上单独把这部分拎出来了,https://github.com/cocos2d/bindings-generator/Bindings Generator 工具它可以将 C++ 类的公共方法和公共属性绑定到脚本层。自动绑定工具尽管非常强大,但是还是会有一些限制: 只能够针对类生成绑定,不可以绑定结构体,独立函数等。 不能够生成 Delegate 类型的 API,因为脚本中的对象是无法继承 C++ 中的 Delegate 类并重写其中的 Delegate 函数的。 子类中重写了父类的 API 的同时,又重载了这个 API。 部分 API 实现内容并没有完全体现在其 API 定义中。 在运行时由 C++ 主动调用的 API。 总结总的来说,自动绑定JSB只需要要求开发者编写相关的实现c++实现类,一个配置文件,然后执行一条命令就能完整整个的绑定过程,如果没有什么特殊定制,相对于手动绑定来说,效率上还是提高了不少,实际工作做可以依据具体情况先用自动绑定功能,然后再去手动修改生成的绑定文件,达到事倍功半的效果。","tags":[{"name":"Cocos Creator","slug":"Cocos-Creator","permalink":"http://oedx.github.io/tags/Cocos-Creator/"},{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"},{"name":"JSB","slug":"JSB","permalink":"http://oedx.github.io/tags/JSB/"}]},{"title":"Cocos Creator 纹理压缩插件化","date":"2019-06-20T19:40:29.000Z","path":"2019/06/20/CocosCreator-TextureCompression-Plugin/","text":"在一些游戏类应用或者移动应用中,图片资源所耗的内存往往是整个App内存占用的大头。在早期开发过程中,为了追求更好的体验效果,往往使用了很多高清的资源,这些高清资源往往占用了很大的内存。 而对于移动端设备来说,内存是有限的,尤其对于一些早期的设备,比如第一代的iPad,RAM才512MB,如果要兼容这些老款的设备,无疑对内存的使用要变得很节制。特别对于iOS设备来说,App的内存不能无限制的使用下去,如果内存占用过多,系统在发出内存警告以后如果还无法释放出更多的空闲内存出来,后面就意味着你的App已经送上了断头台,有随时被系统咔嚓掉的风险。为提升App的稳定性以及执行的效率,降低内存占用是每个开发人员一直在追求的目标。 如何降图片内存通常设计师给的图片格式有PNG、JPG、TGA等,比如png图片,一张1024*1024分辨率的图片,文件大小才几百KB甚至更小,这些图片是经过特殊编码,减少了冗余信息,降低了存储空间的占用,易于网络传输。然而这些格式的图片读到内存以后,并不能直接交个GPU进行绘制,需要通过CPU解码成位图以后,才能给GPU绘制。以32位png格式图片,分辨率1024*1024图片为例,解码成位图以后占用的内存大小为4 * 1024 * 1024 * 8 bit,占用内存为4MB。降低图片内存占用,有下面常见方法: 常见方法 降低图片分辨率:根据实际情况,在不影响整体体验效果的同时,适当降低分辨率。 减小图片每个像素的位数:比如将32位png图片转成了24位、16位,甚至更低,根据实际效果决定。 使用调色板技术:每个像素只是调色板的索引,大幅降低内存占用。这个只能对颜色数少的图片使用,颜色丰富的图片,颜色失真,效果很差。 纹理压缩:使用标准的压缩算法对纹理进行压缩。 纹理压缩简单来说,就是将原始位图经过标准的算法进行编码压缩,压缩后的数据能够直接被GPU读取解码渲染。减少了CPU解码的过程以及转成bitmap的内存占用。因此使用纹理压缩,可有效降低内存的占用以及提升渲染效率。 常用的纹理压缩格式DXTDXT纹理压缩格式来源于S3(Silicon & Software Systems)公司提出的S3TC,基本思想是把4x4的像素块压缩成一个64或128位的数据块,是有损压缩方式。 S3TC算法有五种变化DXT1-DXT5,一般在Windows设备上面使用,支持的GPU: Windows\\Android(Nvidia Tegra and Intel Bay Trail)。 五种算法的格式对比如下表: ETCEricsson Texture Compression,是由 Khronos 支持的开放标准,在移动平台中广泛采用。它是一种为感知质量设计的有损算法,其依据是人眼对亮度改变的反应要高于色度改变。类似于DXT,ETC也是把4x4的像素块压缩成一个64或128位的数据块,也是有损压缩。ETC有两种压缩格式:ETC1和ETC2。 ETC1ETC1是早期推出的纹理压缩格式,也是兼容性最好的格式。基本上上能够兼容所有支持OpenGLES2.0的Android设备,但是iOS设备都不支持。ETC1不支持Alpha通道,如果需要支持Alpha,需要经过特殊的处理才能支持:将图片的Alpha通道分离出来,跟24位RGB图合成一张原来2倍大小的图,通过自定义shadder来绘制。Cocos Creator里面的做法可以参考教程:Cocos Creator 支持ETC1 + Alpha 纹理压缩。 ETC2ETC2 是ETC1的扩展,向下兼容ETC1,对RGB压缩质量较好并且支持Alpha通道。ETC2相比ETC1压缩质量更高,从视觉上看也是接近了原图的效果。不过ETC2的兼容性不太好,OpenGLES3.0才把它纳入标准,而且对硬件要求也较高,iOS设备需要苹果A7以上的设备才能支持,Android大部分设备都支持,但是不同厂商差异很大,经过测试,发现有些Android 6.0的设备都不支持。两种格式对比如下: PVRTCPowerVR Texture Compression,PVRTC格式与基于块的压缩格式,比如S3TC、ETC的不同之处是,它使用2张双线性放大的低分辨率图,根据精度和每个像素的权重,融合到一起来呈现纹理,并且2-bpp和4-bpp都支持ARGB数据。PVRTC格式压缩比高,也是有损压缩。PVRTC有2bpp和4bpp格式,2bpp每个像素2bit,压缩率很高,但是质量较差。4bpp每个像素4bit,质量相对更好一些。具体用哪个,需要根据实际情况而定。PVRTC压缩对图片的规格要求较高,图片的大小必需是正方形而且边长必需是2的N次幂。该纹理压缩对iOS兼容性好,支持所有的iOS设备以及部分使用PowerVR GPU的Android设备。 后来Imagenation 公司推出了PVRTC2纹理压缩格式,它是对PVRTC进行的改进,添加了NPOT(非2方)和贴图集支持,而且压缩质量也有很大的提高。但是必需有PowerVR Series5XT或Series6 GPU 才支持,苹果设备没有支持,估计这个格式也是凉凉了。 ASTC自适应可伸缩纹理压缩(Adaptive Scalable Texture Compression,ASTC)是一种基于像素块的有损纹理压缩算法,由ARM的Jørn Nystad及其他人员共同设计。在2012年8月6日,ASTC被Khronos Group正式采纳为OpenGL和OpenGL ES的正式扩展。ASTC同样是基于block的压缩方式,但块的大小却较支持多种尺寸,比如从基本的4x4到12x12,而且块的宽高也不限于pot,比如6x5;每个块内的内容用128bits来进行存储,因而不同的块就对应着不同的压缩率。兼容性方面,OpenGLES3.0以上支持,支持多数Android 高端机器以及iPhone 6以上机型。压缩率如下表: Block Size Bits Per Pixel Comp.Ratio 4x4 8.00 4:1 5x4 6.40 5:1 5x5 5.12 6.25:1 6x5 4.27 7.5:1 6x6 3.56 9:1 8x5 3.20 10:1 8x6 2.67 12:1 10x5 2.56 12.5:1 10x6 2.13 15:1 8x8 2.00 16:1 10x8 1.60 20:1 10x10 1.28 25:1 12x10 1.07 30:1 12x12 0.89 36:1 Cocos Creator 纹理压缩插件化从上面纹理压缩的介绍可以看出,没有任何一种格式是完美的,都有它使用的场景以及局限性在里面。ABCmouse是一款类游戏的儿童英语教学App,使用的Cocos Creator进行开发并发布到全平台。由于App需要兼容老款低端的设备,因此我们采用了混合压缩的方式: iOS采用PVR + ETC2进行纹理压缩 Android采用ETC1进行纹理压缩 根据具体情况,有些图片如果无法保证质量,将采用原图而不进行纹理压缩,以保证设计需要的效果 为什么要插件化了解Cocos Creator的开发应该清楚,每次新开发功能,修改图片或者脚本以后,如果需要看看在真机上的效果,都需要在Cocos Creator上触发一次构建。建构完成以后,会在build目录下生成一个包含所有图片的目录。如果需要纹理压缩,需要手动触发一下纹理压缩的工具,这种流程非常频繁而且过于机械化。因此,我们考虑将纹理压缩功能以插件的形式集成到Cocos Creator,在Cocos Creator构建完成以后自动触发纹理压缩,使整个流程更加自动化。 原理Cocos Creator插件能力,官方有文档可以参考。关键的一步,就是监听Cocos Creator构建完成的事件 editor:build-finished,在监听到这个事件以后启动纹理压缩。 123456789101112'editor:build-finished': function (event, target) { if (packerOpened) { process.nextTick(() => { texPacker.handleNoPack(); texPacker.generateImgResourceMap(); Editor.log('开始压缩纹理...'); texPacker.startPack(); }); } else { Editor.log('纹理压缩已关闭,不进行纹理压缩。'); }} 我们也在插件中提供了一些菜单选项,可以临时打开或者关闭纹理压缩,开启关闭纹理压缩菜单: 123456789'packtexture:opentexturepack'() { packerOpened = true; Editor.log('纹理压缩已开启');},'packtexture:closetexturepack'() { packerOpened = false; Editor.log('纹理压缩已关闭');}, 纹理压缩以后,我们发现压缩以后的纹理比原始的JPG、PNG图片更大,影响到安装包的大小。因此,最后在纹理压缩完成以后再采用了gzip压缩,最后安装包不会明显增长,甚至有所降低: 123456789101112131415161718192021222324252627282930packETCAplha: function (srcPath, sfile, dfile, callBack, dfile2) { let packTemp = sfile.replace(/\\.[^/.]+$/, ""); let outputPath = packTemp + '.pkm'; let shell = 'etcpack ' + sfile + ' ' + srcPath + ' -c etc -aa'; exec(shell, { encoding: 'utf8', env: process.env }, (err, stdout, stderr) => { let finish = true; if (fs.existsSync(outputPath)) { fs.createReadStream(outputPath) .pipe(zlib.createGzip()) .pipe(fs.createWriteStream(dfile)); if (dfile2) { fs.createReadStream(outputPath) .pipe(zlib.createGzip()) .pipe(fs.createWriteStream(dfile2)); } fs.unlinkSync(outputPath); } else { finish = false; let buffer = fs.readFileSync(sfile); fs.writeFileSync(dfile, buffer); Editor.log('压缩ETC+Alpha 失败, ' + sfile); } if (callBack) { callBack(finish, dfile); } });}, 增量压缩有些比较大的图片,压缩一次的耗时是比较长的,需要几秒钟。对于一个稍具规模的项目来说,图片数量有很多,压缩一次耗时很长,需要几十秒甚至几分钟时间。如果每次都全量重新压缩一次的话,耗费时间太久,影响开发的效率。 因此,我们根据图片的md5值进行对比,只对新增加或者有修改的图片进行压缩,无修改的图片直接使用上次压缩的结果,大大节省了纹理压缩的时间,提升了开发效率。 全量压缩,可以看出每一项压缩完耗时都较长: 增量压缩,每项检查都很快: 我们这个插件已经开源CocosCreator 纹理压缩插件。 总结纹理压缩是降低图片内存占用节省GPU带宽的有效手段,对于图片占比很多的场景还是可以考虑使用纹理压缩。本文介绍了当下常见的一些纹理压缩格式,简要分析了其特点。具体选择哪种压缩格式的时候,需要结合实际情况,采用一种或者多种格式想结合的方式,获得最终想要的结果。后面简单介绍了纹理压缩工具以插件形式和Cocos Creator结合得例子,希望对有遇到类似问题的人有些帮助。","tags":[{"name":"Cocos Creator","slug":"Cocos-Creator","permalink":"http://oedx.github.io/tags/Cocos-Creator/"}]},{"title":"【Cocos Creator】拒绝 evalString 进行回调,使用 JSB 进行手动绑定(流程篇)","date":"2019-05-29T17:52:46.000Z","path":"2019/05/29/cocos-creator-js-binding-manual/","text":"背景一直以来,ABCmouse 项目中的整体 JS/Native 通信调用结构都是基于 callStaticMethod <-> evalString 的方式。通过 callStaticMethod 方法我们可以通过反射机制直接在 JavaScript 中调用 Java / Objective-C 的静态方法。而通过 evalString 方式,则可以执行 JS 代码,这样便可以进行双端通信。 新版 ABCmouse 的应用架构:基于 callStaticMethod 与 evalString 进行通信 虽然基于这个方式上层封装接口后,新增业务逻辑会比较方便。但是过度依赖 evalString ,往往也会带来一些隐患。举个 Android 侧的例子:1Cocos2dxJavascriptJavaBridge.evalString("window.sample.testEval('" + param + "',JSON.stringify(" + jsonObj + "))"); 对于常见的参数结构,这样运行是没有问题的,然而基于实际场景的种种情况,我们会发现针对引号的控制格外重要。如代码所示,为了保证 JS 代码能够被正确执行,我们在拼接字符串时必须明确 '与 " 的使用,稍有不慎就会出现 evalString 失败的情况。在 Cocos 的官方论坛上,从大量的反馈中我们也能了解这里的确是一个十分容易踩坑的地方。而另一方面,对于我们项目本身而言,过度依赖 evalString 所产生的种种不确定因素也往往很难掌控,我们又不能一味地通过 try/catch 去解决。所幸的是,经过全局业务排查,目前项目中在绝大多数因此,在查阅官方文档后,我们决定绕过 evalString ,直接基于 JSB 绑定的方式进行通信。 这里以下载器的接入为例。在我们的项目中,下载器是在 Android 与 iOS 侧分别各自实现。在改造之前的版本中,下载器的调用与回调基于 callStaticMethod <-> evalString 的方式。每次调用下载都需要这样执行:12345if(cc.sys.isNative && cc.sys.os == cc.sys.OS_IOS) { jsb.reflection.callStaticMethod('ABCFileDownloader', 'downloadFileWithUrl:cookie:savePath:', url, cookies, savePath);} else if(cc.sys.isNative && cc.sys.os == cc.sys.OS_ANDROID) { jsb.reflection.callStaticMethod("com/tencent/abcmouse/downloader/ABCFileDownloader", "downloadFileWithUrl", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", url, cookies, savePath);} 下载成功抑或是失败都需要通过拼接出类似如下的语句执行 JS:1234567StringBuilder sb = new StringBuilder(JS_STRING_ON_DOWNLOAD_FINISH + "(");sb.append("'" + success + "',");sb.append("'" + url + "',");sb.append("'" + savePath + "',");sb.append("'" + msg + "',");sb.append("'" +code + "')");Cocos2dxJavascriptJavaBridge.evalString(sb.toString()); 无论是调用抑或是回调都拼接繁琐又容易出错,全部数据不得不转化为字符串(emmmm也不美观),而且还要考虑到evalString的执行效率问题。如果只是仅有的少数业务场景在使用尚勉强接受,但是当业务日趋复杂庞大,如果都要这样写,同时又没有详细的文档去规范约束,其后期维护成本可想而知。 而当使用 JSB 改造后,我们调用只需如下寥寥几行代码且无需区分平台,更不必担心上述拼接隐患,相比之下逻辑要清晰许多:123jsb.fileDownloader.requestDownload(url, savePath, cookies, options, (success, url, savePath, msg, code) => { // do whatever you want}); 那么接下来就以一个最简单的下载器的绑定流程为例,我来带大家学习下 JSB 手动绑定的大致流程。(虽然 Cocos 很人性化提供了自动绑定的配置文件,可以通过一些配置直接生成目标文件,减少了很多工作量。但是亲手来完成一次手动绑定的流程会帮助更为全面地了解整个绑定的实现流程,有助于加深理解。另一方面,当存在特殊需要自动绑定无法满足时,手动绑定也往往会更为灵活) 前置在开始之前,我们需要需要知道有关 ScriptEngine 抽象层、相关 API 等相关知识,这部分内容如果已从 Cocos 文档了解可跳过直接进行 实践 部分。 抽象层 首先先来看一下上图 Cocos 官方提供的一张抽象层架构,在1.7版本中,抽象层被设计为一个与引擎没有关系的独立模块,对 JS 引擎的管理从 ScriptingCore 被移动到了 se::ScriptEngine 类中,ScriptingCore 被保留下来是希望通过它把引擎的一些事件传递给封装层,充当适配器的角色。在这个抽象层提供了对 JavaScriptCore、SpiderMonkey、V8、ChakraCore 等多种可选的 JS 执行引擎的封装。JSB 的大部分工作其实就是设定 JS 相关操作的 C++ 回调,在回调函数中关联 C++ 对象。它其实主要包含如下两种类型: 注册 JS 函数(包含全局函数,类构造函数、类析构函数、类成员函数,类静态成员函数),绑定一个 C++ 回调 注册 JS 对象的属性读写访问器,分别绑定读与写的 C++ 回调 考虑到不同多种 JS 引擎的关键方法的定义各不相同,Cocos 团队使用 宏 来抹平这种回调函数定义与参数类型的差异,这里就不展开,详细可阅读文末Cocos Creator 的相关文档。值得一提的是,ScriptEngine 这层设计之初 Cocos 团队就将其定义为一个独立模块,完全不依赖 Cocos 引擎。我们开发者完全可以把 cocos/scripting/js-bindings/jswrapper 下的所有抽象层源码移植到其他项目中直接使用。 SE 类型C++ 抽象层所有的类型都在 se 命名空间下,其为 ScriptEngine 的缩写。 se::ScriptEngine它是 JS 引擎的管理员,掌管 JS 引擎初始化、销毁、重启、Native 模块注册、加载脚本、强制垃圾回收、JS 异常清理、是否启用调试器。 它是一个单例,可通过 se::ScriptEngine::getInstance() 得到对应的实例。 se::Value可以被理解为 JS 变量在 C++ 层的引用。JS 变量有 object, number, string, boolean, null, undefined 六种类型,因此 se::Value 使用 union 包含 object, number, string, boolean 4 种有值类型,无值类型: null, undefined 可由私有变量 _type 直接表示。如果 se::Value 中保存基础数据类型,比如 number,string,boolean,其内部是直接存储一份值副本。 object 的存储比较特殊,是通过 se::Object* 对 JS 对象的弱引用。 se::Object继承于 se::RefCounter 引用计数管理类,它保存了对 JS 对象的弱引用。我们在绑定回调中如果需要用到当前对象对应的 se::Object,只需要通过 s.thisObject() 即可获取。其中 s 为 se::State 类型。 se::Class用于暴露 C++ 类到 JS 中,它会在 JS 中创建一个对应名称的构造函数。Class 类型创建后,不需要手动释放内存,它会被封装层自动处理。se::Class提供了一些 API 用于定义 Class 的创建、静态/动态成员函数、属性读写等等,后面在实践时用到会做介绍。完整内容可查阅 Cocos 文档。 se::State它是绑定回调中的一个环境,我们通过 se::State 可以取得当前的 C++ 指针、se::Object 对象指针、参数列表、返回值引用。 宏前面有提到, 抽象层使用宏来抹平不同 JS 引擎关键函数定义与参数类型的不同,不管底层是使用什么引擎,开发者统一使用一种函数的定义。 例如,抽象层所有的 JS 到 C++ 的回调函数的定义为:123456bool foo(se::State& s){ ... ...}SE_BIND_FUNC(foo) // 此处以回调函数的定义为例 我们在编写完回调函数后,需要记住使用 SE_BIND_XXX 系列的宏对回调函数进行包装。目前全部的 SE_BIND_XXX 宏如下所示。 SE_BIND_PROP_GET:包装一个 JS 对象属性读取的回调函数 SE_BIND_PROP_SET:包装一个 JS 对象属性写入的回调函数 SE_BIND_FUNC:包装一个 JS 函数,可用于全局函数、类成员函数、类静态函数 SE_DECLARE_FUNC:声明一个 JS 函数,一般在 .h 头文件中使用 SE_BIND_CTOR:包装一个 JS 构造函数 SE_BIND_SUB_CLS_CTOR:包装一个 JS 子类的构造函数,此子类使用 cc.Class.extend 继承 Native 绑定类 SE_FINALIZE_FUNC:包装一个 JS 对象被 GC 回收后的回调函数 SE_DECLARE_FINALIZE_FUNC:声明一个 JS 对象被 GC 回收后的回调函数 _SE:包装回调函数的名称,转义为每个 JS 引擎能够识别的回调函数的定义,注意,第一个字符为下划线,类似 Windows 下用的_T(“xxx”)来包装 Unicode 或者 MultiBytes 字符串 在我们的简化版例子中,只需要用到 SE_DECLARE_FUNC、SE_BIND_FUNC即可。 类型转换辅助函数类型转换辅助函数位于 cocos/scripting/js-bindings/manual/jsb_conversions.hpp/.cpp 中,包含了多种 se::Value 与 C++ 类型相互转化的方法。 bool std_string_to_seval(const std::string& v, se::Value* ret); bool seval_to_std_string(const se::Value& v, std::string* ret); bool boolean_to_seval(bool v, se::Value* ret); bool seval_to_boolean(const se::Value& v, bool* ret);… … 实践在开始之前,我们需要明确一下流程。JSB 绑定简单来讲就是在C++层实现一些类库,然后经过一些特定处理可以在 JS 端进行对应方法调用的过程。因为采用 JS 为主要业务编写语言,使得我们在做一些 Native 的功能时会比较受限,例如文件、网络等等相关操作。 以 Cocos2d-js 文档中 cc.Sprite 为例,在 JSB 中 如果使用 new 操作符来调用 cc.Sprite 的构造函数,实际上在 C++ 层会调用 js_cocos2dx_Sprite_constructor 函数。在这个 C++ 函数中,会为这个精灵对象分配内存,并把它添加到自动回收池,然后调用 JS 层的 _ctor 函数来完成初始化。在 _ctor 函数中会根据参数类型和数量调用不同的init函数,这些init函数也是C++函数的绑定:1234567891011121314151617181920#define SE_BIND_CTOR(funcName, cls, finalizeCb) \\ void funcName##Registry(const v8::FunctionCallbackInfo<v8::Value>& _v8args) \\ { \\ v8::Isolate* _isolate = _v8args.GetIsolate(); \\ v8::HandleScope _hs(_isolate); \\ bool ret = true; \\ se::ValueArray args; \\ se::internal::jsToSeArgs(_v8args, &args); \\ se::Object* thisObject = se::Object::_createJSObject(cls, _v8args.This()); \\ thisObject->_setFinalizeCallback(_SE(finalizeCb)); \\ se::State state(thisObject, args); \\ ret = funcName(state); \\ if (!ret) { \\ SE_LOGE("[ERROR] Failed to invoke %s, location: %s:%d\\n", #funcName, __FILE__, __LINE__); \\ } \\ se::Value _property; \\ bool _found = false; \\ _found = thisObject->getProperty("_ctor", &_property); \\ if (_found) _property.toObject()->call(args, thisObject); \\ } 三层的方法对应关系如下: Javascript JSB Cocos2d-x cc.Sprite.initWithSpriteFrameName js_cocos2dx_Sprite_initWithSpriteFrameName cocos2d::Sprite::initWithSpriteFrameName cc.Sprite.initWithSpriteFrame js_cocos2dx_Sprite_initWithSpriteFrame cocos2d::Sprite::initWithSpriteFrame cc.Sprite.initWithFile js_cocos2dx_Sprite_initWithFile cocos2d::Sprite::initWithFile cc.Sprite.initWithTexture js_cocos2dx_Sprite_initWithTexture cocos2d::Sprite::initWithTexture 这个调用过程的时序如下: 调用时序图(引自 Cocos2d-js 文档) 和上面的过程类似。首先,我们需要确定接口和字段,我们随便拟定一个最简单的下载器 FileDownloader,它所具备的是 download(url, path, callback) 接口,而在 callback 中我们需要拿到的则是 code,msg。并且为了方便使用,我们将它挂载在 jsb 对象下,这样我们便可以使用如下代码进行简单地调用:123jsb.fileDownloader.download(url, path, (msg, code) => { // do whatever you want}); 确定接口后,我们可以开始着手码 C++ 部分了。首先来一发 FileDownloader.h,作为公共头文件供 Android/iOS 使用。接着 Android/iOS 分别实现各自的具体下载实现即可(此处略过),reqCtx 则用于存储回调对应关系:1234567891011121314class FileDownloader { public: typedef std::function<void(const std::string& msg, const int code)> ResultCallback; static FileDownloader* getInstance(); static void destroyInstance(); void download(const std::string& url, const std::string& savePath, const ResultCallback& callback); void onDownloadResult(const std::string msg, const int code); ... ... protected: static FileDownloader* s_sharedFileDownloader; std::unordered_map<std::string, ResultCallback> reqCtx;}; 接下来我们进行最关键的绑定部分。因为下载器就功能上分类属于 network 模块,我们可以选择将我们的 FileDownloader 的绑定实现在 Cocos 源码中现有的 jsb_cocos2dx_network_auto 中。在 jsb_cocos2dx_network_auto.hpp 中声明 JS 函数:12SE_DECLARE_FUNC(js_cocos2dx_network_FileDownloader_download); // 声明成员函数,下载调用SE_DECLARE_FUNC(js_cocos2dx_network_FileDownloader_getInstance); // 声明静态函数,获取单例 随后在 jsb_cocos2dx_network_auto.cpp 中来注册 FileDownloader 和新声明的这两个函数到 JS 虚拟机中。首先先写好对应的两个方法实现留空,等注册逻辑完成后再来补全:1234567891011static bool js_cocos2dx_network_FileDownloader_download(se::State &s) { // 方法名与声明时一致 // TODO}SE_BIND_FUNC(js_cocos2dx_network_FileDownloader_download); // 包装该方法static bool js_cocos2dx_network_FileDownloader_getInstance(se::State& s) { // 方法名与声明时一致 // TODO}SE_BIND_FUNC(js_cocos2dx_network_FileDownloader_getInstance); // 包装该方法 现在我们开始编写注册逻辑,新增一个注册方法用于收归 FileDownloader 的全部注册逻辑:123456789bool js_register_cocos2dx_network_FileDownloader(se::Object* obj) { auto cls = se::Class::create("FileDownloader", obj, nullptr, nullptr); cls->defineFunction("download", _SE(js_cocos2dx_network_FileDownloader_download)); cls->defineStaticFunction("getInstance", _SE(js_cocos2dx_network_FileDownloader_getInstance)); cls->install(); JSBClassType::registerClass<cocos2d::network::FileDownloader>(cls); se::ScriptEngine::getInstance()->clearException(); return true;} 我们来看看这个方法里做了些什么重要的事情: 调用 se::Class::create(className, obj, parentProto, ctor) 方法,创建了一个名为 FileDownloader 的 Class,注册成功后,在 JS 层中可以通过 let xxx = new FileDownloader();的方式创建实例。 调用 defineFunction(name, func) 方法,定义了一个成员函数 download,并将其实现绑定到包装后的 js_cocos2dx_network_FileDownloader_download 上。 调用 defineStaticFunction(name, func) 方法,定义了一个静态成员函数 getInstance,并将其实现绑定到包装后的 js_cocos2dx_network_FileDownloader_getInstance 上。 调用 install() 方法,将自己注册到 JS 虚拟机中。 调用 JSBClassType::registerClass 方法,将 生成的 Class 与 C++ 层的类对应起来(内部通过 std::unordered_map<std::string, se::Class*>实现)。 通过以上这几步,我们完成了关键的注册部分,当然不要忘记在 network 模块的注册入口添加 js_register_cocos2dx_network_FileDownloader 的调用:12345678910111213141516171819bool register_all_cocos2dx_network(se::Object* obj){ // Get the ns se::Value nsVal; if (!obj->getProperty("jsb", &nsVal)) { se::HandleObject jsobj(se::Object::createPlainObject()); nsVal.setObject(jsobj); obj->setProperty("jsb", nsVal); } se::Object* ns = nsVal.toObject(); ... ... // 将前面生成的 Class 注册 设置为 jsb 的一个属性,这样我们便能通过 // let downloader = new jsb.FileDownloader(); // 获取实例 js_register_cocos2dx_network_FileDownloader(ns); return true;} 完成这一步,我们的 Class 已经成功绑定,现在回来继续完善刚才留空的方法。 首先是 getInstance(): 1234567891011121314static bool js_cocos2dx_network_FileDownloader_getInstance(se::State& s){ const auto& args = s.args(); size_t argc = args.size(); CC_UNUSED bool ok = true; if (argc == 0) { cocos2d::network::FileDownloader* result = cocos2d::network::FileDownloader::getInstance(); // C++ 单例 ok &= native_ptr_to_seval<cocos2d::network::FileDownloader>((cocos2d::network::FileDownloader*)result, &s.rval()); SE_PRECONDITION2(ok, false, "js_cocos2dx_network_FileDownloader_getInstance : Error processing arguments"); return true; } SE_REPORT_ERROR("wrong number of arguments: %d, was expecting %d", (int)argc, 0); return false;} 前面提到,我们可以通过 se::State 获取到 C++ 指针、se::Object 对象指针、参数列表、返回值引用。梳理逻辑如下: args() 获取 JS 带过来的全部参数(se::Value 的 vector); 参数个数判断,因为这里的 getInstance() 并不需要额外参数,因此参数为 0; native_ptr_to_seval() 用于在绑定层根据一个 C++ 对象指针获取一个 se::Value,并赋返回值给rval()至 JS 层; 到这里,getInstance()的绑定层逻辑已全部完成,我们已经可以通过:let downloader = jsb.FileDownloader.getInstance() 获取实例了。 接着是 download(): 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657static bool js_cocos2dx_network_FileDownloader_download(se::State &s) { cocos2d::network::FileDownloader *cobj = (cocos2d::network::FileDownloader *) s.nativeThisObject(); SE_PRECONDITION2(cobj, false, "js_cocos2dx_network_FileDownloader_download : Invalid Native Object"); const auto &args = s.args(); size_t argc = args.size(); CC_UNUSED bool ok = true; if (argc == 3) { std::string url; std::string path; ok &= seval_to_std_string(args[0], &url); // 转化为std::string url ok &= seval_to_std_string(args[1], &path); // 转化为std::string path std::function<void(const std::string& msg, const int code)> callback; do { if (args[2].isObject() && args[2].toObject()->isFunction()) { se::Value jsThis(s.thisObject()); // 获取 JS 回调 se::Value jsFunc(args[2]); // 如果目标类是一个单例则不能用 se::Object::attachObject 去关联 // 必须使用 se::Object::root,无需关心 unroot,unroot 的操作会随着 lambda 的销毁触发 jsFunc 的析构,在 se::Object 的析构函数中进行 unroot 操作。 // 如果使用 s.thisObject->attachObject(jsFunc.toObject);会导致对应的 func 和 target 永远无法被释放,引发内存泄露。 jsFunc.toObject()->root(); auto lambda = [=](const std::string& msg, const int code) -> void { se::ScriptEngine::getInstance()->clearException(); se::AutoHandleScope hs; CC_UNUSED bool ok = true; se::ValueArray args; args.resize(2); ok &= std_string_to_seval(msg, &args[0]); ok &= int32_to_seval(code, &args[1]); se::Value rval; se::Object* thisObj = jsThis.isObject() ? jsThis.toObject() : nullptr; se::Object* funcObj = jsFunc.toObject(); // 执行 JS 方法回调 bool succeed = funcObj->call(args, thisObj, &rval); if (!succeed) { se::ScriptEngine::getInstance()->clearException(); } }; callback = lambda; } else { callback = nullptr; } } while(false) ; SE_PRECONDITION2(ok, false, "js_cocos2dx_network_FileDownloader_download : Error processing arguments"); cobj->download(url, path, callback); return true; } SE_REPORT_ERROR("wrong number of arguments: %d, was expecting %d", (int) argc, 3); return false;} 通过 seval_to_std_string 方法获取转化 C++ 后的 url、path 参数和原始 jsFunc。 手动构造回调 function,将 msg 和 code 转化为 se::Value。 通过 funcObj->call 执行 JS 方法进行回调。 以上即为一次普通调用的回调的执行过程的绑定。现在我们还剩下一些收尾工作,我们需要将 FileDownloader 真正成为单例,在 JS 层无需手动实例化即可使用。因为下载器属于通用组件,所以我们需要尽早将其实例化并成功挂载,因此我们需要修改 jsb_boot.js,这个文件会在 Cocos 引擎初始化时调用,我们在其中补充如下代码: 123// FileDownloaderjsb.fileDownloader = jsb.FileDownloader.getInstance();delete jsb.FileDownloader; 最后,考虑到内存释放的风险,我们还需要在 CCDirector.cpp 中的 reset() 方法中进行相关回收: 1network::FileDownloader::destroyInstance(); ================================================ 以上就是全部的绑定流程,在分别编译到 Android/iOS 环境后,我们就能够通过 jsb.fileDownloader.download() 进行下载调用了。(PS:一定切记在使用前进行 CC_JSB 的宏判断,因为非 JSB 环境下是无法使用的) 总结我们现在来总结一下手动绑定改造的详细流程。一般而言,常用的 JSB 的改造流程大致如下: 确定方法接口与 JS/Native 公共字段 声明头文件,并分别实现 Android JNI 与 OC 具体业务代码 编写抽象层代码,将必要的类与对应方法注册到 JS 虚拟机中 将绑定的类挂载在 JS 中的指定对象(类似命名空间)中 在下一篇(原理篇)中,我们会继续从源码详细分析 ScriptEngine 的结构和完整的 JSB 调用原理,敬请期待。","tags":[{"name":"Cocos Creator","slug":"Cocos-Creator","permalink":"http://oedx.github.io/tags/Cocos-Creator/"},{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"},{"name":"JSB","slug":"JSB","permalink":"http://oedx.github.io/tags/JSB/"},{"name":"JS引擎","slug":"JS引擎","permalink":"http://oedx.github.io/tags/JS引擎/"}]},{"title":"前端学Serverless系列--性能调优","date":"2019-05-29T16:47:35.000Z","path":"2019/05/29/serverless-performance/","text":"导语:Serverless云函数的优点是不怕高并发,理论上无限自动扩容,缺点是冷启动特性导致冷启动的时延比较高。那么实际上性能如何,并且是否还有性能优化的空间和手段呢? 最近试点Serverless的一个项目是从原有的node服务迁移到腾讯云函数Serverless的。既然是项目迁移,那么就要对比一下迁移前后的性能了。 压测方案从测试同事那很快就找到压测大师这个工具,压测大师配置和报告都还算比较完善,是腾讯出的,内部的话用企业微信登录直接免费可以使用。 缺点是:发请求的时间间隔有限制,最大只能隔1分钟,那就无法纯测试云函数的冷启动了,不过每个阶段都有自动增加人数,页能间接测试到冷启动到性能,看到测试结果时不时飙升到高耗时,那就多数就是冷启动的耗时冷。 压测大师链接:https://wetest.qq.com 如果要测200以内的并发人数的话,可以直接测试了。如果需要测高于200人数的并发量的话,需要先进行域名验证(具体可以看压测大师提示并操作即可),就是放一个key到你的域名根目录下,可以让压测大师可以访问到,不然让你随便压别人的域名怎么办? 这又难倒了我。因为腾讯云自动生成的api网关链接下必然会有http://yourdomain/release/这个环境的路径,根本无法将key放到根目录下。 腾讯云也提供了一个路径映射的功能,可以将这个/release/的路径去掉,但是这个功能绑定在了自定义域名中,就是说,首先你得有一个自己的域名。 这个问题和腾讯云的产品反馈之后,将这个需求加到了排期中。 暂时我从同事那借用了一个子域名先进行继续将测试工作进行下去。 注意:需要在腾讯云备案了的域名。可以绑定一个子域名,在个人的域名管理界面中,通过cname指向腾讯云的API网关域名即可。 如果你要支持https的话,还需要申请个证书。有免费的可以申请,同一个主域名可以申请20个。申请完成之后,将证书上传到腾讯云即可。 如果不需要https都话,直接选http就可以了,准备工作都OK了。 评测及优化选择测试的CGI,初步是要比较两者的链路,所以选取一个简单的逻辑,只有一个数据库查询的CGI。 压力从低到高进行尝试。 刚尝试到200并发人数到时候,成功率就有点不对劲了,只有80%多? 分析问题后来查了一下云函数的日志,问题就比较明了了。 虽然说是理论上可以无限扩容,但是也要配置给不给你这个上限。 优化方案如果有需求可以联系腾讯云的同事进行相关资源的申请。 在提高限额和初始化一个资源池待用之后,再来压测一下: 从上图可以看到成功率上去了,但是平均耗时和耗时长的部分还是不少。 分析问题将耗时比较长的拿出来和云函数的开发一起分析一下,耗时耗在哪里了。 1、 有资源池,冷启动的情况下: 1)资源申请:代码比较大,代码解压+load代码 约2.5s+(解压2.4s),如果加上下载估计会在3s多。 如果调用很频繁,正常情况会有2、3百个请求(资源池容器数量)资源申请时间在2.5-3s之间; 如果调用不频繁,耗时2.5s-3s的请求数会更多一些。 2)函数调用:代码本身执行时间:70ms~115ms左右,路径耗时从5ms~600ms不等 2、热启动:也就是代码本身执行的时间,这个看代码本身要做的逻辑。机器不是瓶颈,主要是逻辑和需要调用第三方应用的耗时。我这个压测例子的耗时就会在50ms之内,20ms左右。 3、现在有一些大于5s的,那些耗时都在路径上或者有可能在单节点处理能力上。具体消耗在哪里暂时看不出来。 优化方案1、设置实例保留,减少冷启动。这个最有效,降幅最大,相当于是保留了一个进程随时响应请求。 2、设置合适的资源池数量,可以大大降低冷启动的耗时。 3、减少代码量,我这个例子代码量从58M降低了26M,主要是将并不常用而且代码量比较大的库拆分成一个单独的云函数服务。 理论上结果应该会好看很多了? 实际上耗时最大的请求的确有所改善,但是平均值和90%的值还是被一些高耗时拉高。 但是实际上压测大师测的结果依然没有达到很理想,下面汇总一下截止目前阶段的结果。 继续分析问题:理论上云函数服务该做的优化都做了,而且理论表现不会这么差才对? 后来云的同事用python写了脚本来自己压测,发现200并发的平均耗时在200ms之内,而且超过200ms的请求也在可数之内。 由于两个测试的结果不一致,从自己写的脚本和wetest压测方案两方展开分析。 1)脚本尽量去还原wetest的方案,如异地部署,阶梯增加并发,并且每个阶段维持30s。 2)直接找到了wetest的后台同事进行问题沟通,尝试了长连接,短连接,多IP压力源,去掉日志打印等操作。 结果:这个压测结果其实已经达到了我们预设的优化目标,平均在200ms之内。 以下还是wetest的压测结果(短连接+多IP)。 其中还有一个很有意思的结果,长连接下的压测结果: 那么到底是什么原因导致了这个结果的差异呢? “网络连接耗时” 在这个例子中,将长连接改成短连接,从一地压测改成多IP压测,效果最为明显,去掉日志打印也一定程度减少了压测源的性能损耗。 用户侧对比评测既然地域不同,网络不同,耗时差异这么大,那么真实的用户耗时到底如何呢? 红色的是Serverless,黄色的是原来的NodeServer。 分析问题1、5月5日针对Serverless做了优化:将非简单请求改成简单请求,减少一次“预检”请求,加上dns-prefetch;加上了NodeServer原来的请求耗时进行对比。刚开始是因为设置了header头,浏览器认为这个是非简单请求。 非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。更详细可以查阅:跨域资源共享 CORS 详解 图为一个请求产生了两个请求,第一个是预检请求,其中method为OPTION, 返回状态码是204。 如果要用到非简单请求的时候,服务端响应CORS的设置要注意到: 如果单选具体的GET,POST,可以在API网关中的API管理中设置是否支持CORS,如果需要支持多个的请求方法的话,就只能后端业务处理。 2、优化之后,发现Serverless的请求依然比NodeServer慢100ms左右。继续分析发现:Serverless的请求会比NodeServer请求多一个Initial connection和SSL的时间。 为什么NodeServer没有呢,因为我们测试的页面和发请求的页面是同域的,所以这部分时间省了。为什么之前Serverless没有发现呢,因为socket连接之后没有那么快销毁,在页面重复刷新的时候,反而是看不到这个耗时的。 需要清理一下socket: chrome://net-internals/#sockets 就可以复现了,那么这个耗时差距的原因就很明显了。 我做了一个简单页面进行测试,发现这样的结果。Serverless和NodeServer的全程耗时其实差不太多,但是TCP连接和SSL时间会长很多。 这是为什么呢?我测试的这个Serverless是部署在上海的,而我在深圳进行测试的。 我再做了一个测试,加了一个部署在广州的云函数。 结果也很明显了。 异地访问部署的问题,因为原有的NodeServer接入了STGW。 STGW全称Secure Tencent Gateway,腾讯安全云网关,是一套实现多网统一接入,支持自动负载均衡的系统。由于STGW提供了就近接入和SSL加速,所以这两部分时间都相应有不少的优化。 那么Serverless能不能用多地部署,就近接入来解决这个问题呢。 目前Serverless的云函数和API网关都是地域隔离的。也就是说广州的API网关对应广州的云函数,不能一个网关对多个地域的云函数。这个需求和腾讯云的产品沟通之后,纳入到以后的规划建设当中。 那么我们目前有没有折中的方案呢? 我们设想了一些中转的方案: 最后我们选择了最右侧的方案,主要是简单。 而且还可以继续通过在header中设置preconnect减少TCP握手和SSL的时间。 <link rel="preconnect" href="//example.com"> preconnect的兼容性情况: 使用之前: 使用了之后的效果: 可以看到Initial connection 和 SSL的时间是可以直接节省掉了,在网络差的情况下,这部分节省的时间更为显著。 小结一下Serverless云函数性能评测和优化结果: 经过优化之后,Serverless云函数的响应性能已经达到了需要即时返回场景的可用状态。 在API网关监控到到耗时(不包括网络时间和握手时间) 1、压测方案、问题分析 1)wetest是腾讯公司支持的通用压测方案,要用好也要对里面提供的设置了解清楚用法和用途。 2)测试不限于压测工具,本地脚本,浏览器network分析,线上用户test,等等都可以尝试用来评测分析。 2、性能优化方案 云方面的优化: 1)上限设置 2)专用资源池 3)实例保持 4)架构升级(测试中) 5)就近接入(待规划) 代码方面的优化: 1)代码逻辑和第三方调用 2)拆分不常用功能,减少代码量 3)使用预加载减少connect的时间(preconnect) 4)接入STGW进行转发 最后1、云函数目前还在起步阶段,应用的过程中还会遇到这样那样的问题,好在腾讯云的同事们(@masonlu等)积极响应,主动和我们一起寻找问题解决问题,最终一起共同成长。 2、云函数目前的特点就是不适合对时延要求比较高的应用,不适合有状态的应用,但是这个不是必然的,这两个问题有来合适的方案之后,就不会再是问题。","tags":[{"name":"Serverless","slug":"Serverless","permalink":"http://oedx.github.io/tags/Serverless/"},{"name":"SCF","slug":"SCF","permalink":"http://oedx.github.io/tags/SCF/"},{"name":"性能调优","slug":"性能调优","permalink":"http://oedx.github.io/tags/性能调优/"}]},{"title":"前端构建秘籍","date":"2019-05-29T14:46:35.000Z","path":"2019/05/29/frontend-packing-tips/","text":"前言随着前端构架工具的不断发展,提供了很多提高我们的开发体验和开发效率的能力,同时构建已经成为前端技术栈中常见的技术。 webpack 也是众多构建工具中崭露头角一员,早期的 webpack 配置复杂难懂,随着其发展,相关配置也不断简化,性能也不断提高,但是对于深入使用的开发人员,通常它的默认配置并不适用于业务开发,需要针对自己业务调整适配。 你对 webpack 了解多少?如何针对业务集成最佳配置?如何优化开发体验?如何开足马力,实现极速的 webpack 的构建性能 🚀?又会有哪些坑 💣?本文带你解答这些问题 🔭。 本文涉及到的所有代码片段的完整代码请参考a8k仓库 -w1078 一、webpack 关键配置项 对构建有所了解的,可直接略过本节 此处不会深入介绍相关配置,更多的详细说明与配置参见官方文档,稍作介绍关键配置项铺垫后面内容。 entrywebpack 查找依赖的入口文件配置,入口文件可以有多个。 单页面应用入口配置通常做法配置:vendor.js 第三方依赖库,polyfill.js 特性填充库,index.js 单页面应用入口文件 12345678// 导出配置module.exports = { entry: { vendor: './src/vendor.js', polyfill: './src/polyfill.js', index: './src/index.js', },}; 多页面应用入口配置 和单页面应用类似,但不同页面会不同有入口文件,这种情况高效的做法就不是直接写死在 entry 里面了,而是通过生成 webpack.config 时,扫描指定目录确定每个页面的入口文件以及所有的页面。 下面举个例子 假定你的页面都放置在 src/pages 目录下面,并且你的每个页面单独一个目录,并且其中有 index.html 和 index.jsx 1234567891011121314151617181920212223242526272829const path = require('path');const fs = require('fs');// 处理公共entryconst commonEntry = ['./src/vendor.js', './src/polyfill.js'];// 页面目录const PAGES_DIR = './src/pages/';const entry = {};// 遍历页面目录const getPages = () => { return fs.readdirSync(PAGES_DIR).filter(item => { let filepath = path.join(PAGES_DIR, item, 'index.js'); if (!fs.existsSync(filepath)) { filepath = `${filepath}x`; // jsx } if (!fs.existsSync(filepath)) { return false; } return true; });};getPages(options).forEach(file => { const name = path.basename(file); // 加入页面需要的公共入口 entry[name] = [...commonEntry, `${PAGES_DIR}/${file}/index`];});// 导出配置module.exports = { entry,}; 入口 boundle 如何插入对应的 html 中? 我们通常需要这个插件HtmlWebpackPlugin自动处理,具体代码如下: 123456789101112131415161718192021222324252627282930313233343536const plugins = [];if (mode === 'single') { // 单页面只需要一次HtmlWebpackPlugin plugins.push( new HtmlWebpackPlugin({ minify: false, filename: 'index.html', template: './src/index.html', }) );}if (mode === 'multi') { // 多页面遍历目录,使用目录下面的html文件 // 不同页面的配置不同,每个页面都单独配置一个html // 所有页面的公共部分可以抽离后,通过模版引擎编译处理 // 具体的方式后面部分loader中提到 const files = getPages(options); files.forEach(file => { const name = path.basename(file); file = `${PAGES_DIR}/${file}/index.html`; // 添加runtime脚本,和页面入口脚本 const chunks = [`runtime~${name}`, name]; plugins.push( new HtmlWebpackPlugin({ minify: false, filename: `${name}.html`, template: file, chunks, }) ); });}// 导出配置module.exports = { plugins,}; output该项配置输出的 bundle 的相关信息,比较常用的配置如下: 123456789101112{ output:{ // name是你配置的entry中key名称,或者优化后chunk的名称 // hash是表示bundle文件名添加文件内容hash值,以便于实现浏览器持久化缓存支持 filename: '[name].[hash].js', // 在script标签上添加crossOrigin,以便于支持跨域脚本的错误堆栈捕获 crossOriginLoading:'anonymous', //静态资源路径,指的是输出到html中的资源路径前缀 publicPath:'https://7.ur.cn/fudao/pc/', path: './dist/',//文件输出路径 }} resolve该项配置主要用于解析模块依赖的自定义项, 比较常规的配置项如下,modules用于加速绝对路径查找效率,alias可以用户自定义模块查找路径。 123456789resolve: { modules: [ path.resolve(__dirname, 'src'), path.resolve(__dirname,'node_modules'), ], alias: { components: path.resolve(__dirname, '/src/components'), },} 扩展如果你使用了绝对路径后,可能就发现vscode智能代码导航就失效了,别慌!请在想目录下面配置jsconfig.json文件解决这个问题,配置和上面对应:123456789101112{ \"compilerOptions\": { \"baseUrl\": \".\", \"paths\": { \"src/*\": [\"./src/*\"], \"components/*\": [\"./src/components/*\"], \"assets/*\": [\"./src/assets/*\"], \"pages/*\": [\"./src/pages/*\"] } }, \"include\": [\"./src/**/*\"]} 这样,你就可以愉快的使用vscode的智能代码提示和导航了! module该项主要配置就是rules了,rules中配置对于不同资源的处理器,是其核心之一,这里简单添加一个示例代码1234567891011121314151617module: { // 这些库都是不依赖其它库的库 不需要解析他们可以加快编译速度 // 通常可以将那些大型的库且已经编译好的库排除,减少webpack对其解析耗时 noParse: /node_modules\\/(moment|chart\\.js)/, rules: [ { test: /\\.jsx?$/, use: resolve('babel-loader'), // 需要被这个loader处理的资源 include: [ path.resolve(projectDir, 'src'), path.resolve(projectDir, 'node_modules/@tencent'), ].filter(Boolean), // 忽略哪些压缩的文件 exclude: [/(.|_)min\\.js$/], } ] optimization该顶配项中最重要最常用的是:splitChunks,minimizer minimizer 可以自己配置输出的文件压缩插件,js压缩我们可以使用webpack集成的uglifyjs,也可以使用Terser,Terser支持es6代码的压缩,同时支持多进程压缩;css压缩我们可以使用optimize-css-assets-webpack-plugin压缩,它使用cssnano作为处理引擎,帮助我们去除重复样式. splitChunks是webpack4.x推出的重磅功能,优化的公共chunk提取策略,更高效的提取公共模块,在后面性能优化中会详细说明其使用方法。 pluginplugin 可以介入整个构建过程任何阶段。例如:报告构建耗时、修改输出代码支持主域重试、添加构建进度报告、代码压缩、资源替换等很多能力都在这里实现。 plugin不展开讨论,因为插件太多了。对于项目需要自己实现插件的,需要注意一点,当你使用插件对输出结果处理时,应当在文件输出到磁盘之前处理,我们以前的构建中主域重试插件就踩了这个坈,导致最终构建的代码出现错误,原因是该插件直接修改磁盘上面的文件,两次构建同时启动,结束时两次构建的插件都修改了磁盘上同一个文件,最终导致bug,并且导致我们需要强行清理发布环境代码才恢复正常发布。 二、开发体验优化舒适的开发体验,有助于提高我们的开发效率,优化开发体验也至关重要 组件热刷新、CSS热刷新自从webpack推出热刷新后,前端开发者在开环境下体验大幅提高。没有热刷新能力,我们修改一个组件后 123456789st=>start: 开始e=>end: 结束op=>operation: 等待构建op1=>operation: 刷新页面op2=>operation: 点击跳转到对应组件op3=>operation: 等待数据刷新op4=>operation: 验证修改st->op(right)->op1(right)->op2(right)->op3->op4->e 加入热构建后: 12345678st=>start: 开始e=>end: 结束op=>operation: 等待构建op1=>operation: 自动下发变化组件op2=>operation: 自动重新渲染组件(状态保持)op4=>operation: 验证修改st->op(right)->op1(right)->op2->op4->e 主要看一下我们业务基于React技术栈,如何在构建中接入热刷新。 无论什么技术栈,都需要在dev模式下加上webpack.HotModuleReplacementPlugin插件 在所有entry中插入require.resolve('../utils/webpackHotDevClient'),webpackHotDevClient这份代码是由react官方的create-react-app提供的 在webpack-dev-server模块的启动参数中添加hot:true 在你需要热加载的js文件中添加以下代码(这段代码在构建生产包会自动删除): 123if (process.env.NODE_ENV==='development' && module.hot) { module.hot.accept()} 注:也可以使用react-hot-loader来实现,具体参考官方文档 SSR热调试辅导的H5/PC项目都有部分页面支持直出,以前直出调试方式是如下流程所示: 12345678910st=>start: 开始e=>end: 结束op=>operation: 修改代码op1=>inputoutput: 构建静态资源(数十秒)op2=>inputoutput: 重新启动serverop3=>operation: 刷新页面op4=>operation: 验证修改cond=>condition: 验证通过?st->op->op1->op2->op3->op4(right)->cond(yes)->econd(no)->op 这种调试流程太长,每一次修改都需要重新构建静态资源,并重启node服务,非常耗时,其次直出模式下,非直出的页面将无法正常访问,整个流程无法走通。 因此, 提出了新的解决方案, 采用 webpack watch+nodemon 结合的模式实现对SSR热调试的支持。node 服务需要的html/js通过webpack插件动态输出,当nodemon检测到变化后将自动重启,html文件中的静态资源全部替换为dev模式下的资源,并保持socket连接自动更新页面。 实现热调试后,调试流程大幅缩短,和普通非直出模式调试体验保持一致。在a8k中通过k dev -s命令即可开启ssr调试模式。下面是SSR热调试的流程图: 123456789st=>start: 开始e=>end: 结束op=>operation: 修改代码op1=>inputoutput: 等待热构建(数秒)op2=>operation: 自动刷新op3=>operation: 验证修改cond=>condition: 验证通过?st->op->op1->op2->op3(right)->cond(yes)->econd(no)->op style调试体验问题: 给style-loader 开启sourceMap后, sourceMap是内联在style文件中的,需要通过link导入,这种方式是通过JavaScript生成blob后丢个link标签解析。之后我们可以在dev工具中直接看到每个样式所在的源文件位置,方便快速的调试样式。但也同样引起一个问题FOUC(页面加载后闪烁),可参见这个ssue 解决方法: 添加singleton: true参数可解决这个问题,但是sourceMap就不能定位到源文件了,而是合并后的文件中的位置,二者不可兼得。所以在a8k工具中提供了可选项,默认开启singleton:true,通过k dev -c可开启cssSourceMap映射 三、性能优化node_modules缓存 辅导大多数项目node_modules依赖数量都非常惊人,辅导PC项目剔除构建相关依赖后,依赖包都1883个,依赖包的安装耗时也就大幅增加,因此减少依赖包安装耗时,对构建整体提升非常重要,方法那就是缓存。 JB系统编译每次编译都会启动一个新的目录,这导致项目依赖的众多node_modules无法缓存,每次编译重新安装耗时非常长,针对JB的编译,我开发了@tencent/im-build模块自动缓存项目依赖的node_modules,大幅提升了编译性能。 OCI编译系统OCI中不需要额外的插件支持,该系统本身已经可以通过配置实现部分目录缓存,二次利用的能力,使用方法如下: 在项目根目录添加.orange-cache.cache文件,并添加你需要缓存的目录 12/node_modules/fudao_qq_com_pc_imt 修改.orange-ci.yml配置,添加缓存配置文件路径 123push: - cacheFrom: .orange-ci.cache #其它配置省略 优化效果优化前 优化后 构建中间结果缓存中间结果缓存优化同样能大幅提升构建性能,对模块的编译本身就是CPU密集型任务。通常来说每次构建并非所有模块都需要被重新处理,可以只考虑处理那些文件内容有变化的模块,那么文件内容没有变化的模块就可以从缓存中获取,通常通过文件内容hash值作为缓存文件的名称,这就是“热构建”。 在webpack中,能够被缓存的内容有:loader处理结果、plugin处理结果、输出文件结果。下面详细说明不同资源不同阶段的缓存方式。 1. babel-loader缓存,通过cacheDirectory开启缓存123456789101112test: /\\.jsx?$/,use: [ { loader: resolve('babel-loader'), options: { babelrc: false, // cacheDirectory 缓存babel编译结果加快重新编译速度 cacheDirectory: path.resolve(options.cache, 'babel-loader'), presets: [[require('babel-preset-imt'), { isSSR }]], }, },], 2. eslint-loader缓存,通过cache选项指定缓存路径12345678910test: /\\.(js|mjs|jsx)$/,enforce: 'pre',use: [ { options: { cache: path.resolve(options.cache, 'eslint-loader'), }, loader: require.resolve('eslint-loader'), },], eslint-loader通常只需要在开发模式下开启,方便及时的提醒开发者,存在eslint错误,及时修复 3. css/scss缓存css-loader/sass-loader/postcss-loader本身并没有提供缓存机制,这里需要用到cache-loader辅助我们实现对css/scss的构建结果缓存,具体使用方式如下: 123456789101112{ loader: resolve('cache-loader'), options: { cacheDirectory: path.join(cache, 'cache-loader-css') },},{ loader: resolve('css-loader'), options: { importLoaders: 2, sourceMap, },},...由于篇幅原因,这里不展示其它更多loader 只需要将该loader添加到这个loader的最头部即可,该loader不仅可以对于css缓存 4. 输出代码压缩缓存,JS压缩引擎多进程处理JS代码压缩我们采用了TerserPlugin插件,具体配置如下: 123456789101112{ // 设置缓存目录 cache: path.resolve(cache, 'terser-webpack-plugin'), parallel: true,// 开启多进程压缩 sourceMap, terserOptions: { compress: { // 删除所有的 `console` 语句 drop_console: true, }, },} 5. CI系统固定缓存目录上面在不同的plugin和loader上面配置了cache目录,对于CI系统来说你需要将cache目录路径固定,以便于重复使用缓存内容,使用方式:JB就配置/tmp/xxx目录,OCI系统可配置在项目目录。 ⚠️注意:由于使用了缓存,当你修改你的编译配置后,需要立即清理缓存结果,最好的做法是在构建工具中自动检测相关配置是否有变化,自动清理缓存 其它优化手段1. 指定绝对路径模块查找路径,加速模块查找12345678resolve: { //加快搜索速度 modules: [ 'node_modules', path.resolve(projectDir, 'src'), path.resolve(projectDir, 'node_modules') ],}, 2. 过滤不需要做任何处理的库1234module: { // 这些库都是不依赖其它库的库 不需要解析他们可以加快编译速度 noParse: /node_modules\\/(moment|chart\\.js)/,} 3. 缩小babel处理范围,避免处理已经压缩的代码1234567// 指处理指定目录的文件include: [ path.resolve(projectDir, 'src'), path.resolve(projectDir, 'node_modules/@tencent'),].filter(Boolean),// 忽略哪些压缩的文件exclude: [/(.|_)min\\.js$/], 4. lodash库按需倒入优化,减少无用代码我们在使用lodash库是,通常只会用到其中非常少的function,但是像下面这段代码,将会导致lodash全部被打入最终的bundle中。12import _ from 'lodash'_.difference(1, 2) 这种情况幸好有插件可以帮我们优化,通过lodashPlugin即可自动处理lodash的按需引用 使用方法如下:12345const LodashPlugin = require('lodash-webpack-plugin');plugins:[ // 支持lodash包 按需引用 new LodashPlugin(),] 加入这个plugin后,上面的代码自动处理为如下代码:12import difference from 'lodash/difference';difference([1, 2], [1, 3]); 注意:导入代码方式必须使用import,不能使用require 5. 针对服务端渲染代码,我们可以剔除node_modules,从而大幅减少服务端代码生成耗时通过webpack-node-externals插件实现这一点,具体使用方法如下:12345678910111213const nodeExternals = require('webpack-node-externals');module.export={// 省略其它配置 externals: [ nodeExternals({ // 注意如果存在src下面其他目录的绝对引用,都需要添加到这里 whitelist: [ /^components/, /^assets/, /^pages/, /^@tencent/, /\\.(scss|css)$/ ], }), ],// 省略其它配置} 6. webpack4.x的鼎力之作之splitChunks在webpack4之前,我们处理公共模块的方式都是使用CommonsChunkPlugin,然后该插件的让开发这配置繁琐,并且公共代码的抽离,不够彻底和细致,因此新的splitChunks改进了这些能力。使用的正确姿势如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344splitChunks: { chunks: 'all', minSize: 10000, // 提高缓存利用率,这需要在http2/spdy maxSize: 0,//没有限制 minChunks: 3,// 共享最少的chunk数,使用次数超过这个值才会被提取 maxAsyncRequests: 5,//最多的异步chunk数 maxInitialRequests: 5,// 最多的同步chunks数 automaticNameDelimiter: '~',// 多页面共用chunk命名分隔符 name: true, cacheGroups: {// 声明的公共chunk vendor: { // 过滤需要打入的模块 test: module => { if (module.resource) { const include = [/[\\\\/]node_modules[\\\\/]/].every(reg => { return reg.test(module.resource); }); const exclude = [/[\\\\/]node_modules[\\\\/](react|redux|antd)/].some(reg => { return reg.test(module.resource); }); return include && !exclude; } return false; }, name: 'vendor', priority: 50,// 确定模块打入的优先级 reuseExistingChunk: true,// 使用复用已经存在的模块 }, react: { test({ resource }) { return /[\\\\/]node_modules[\\\\/](react|redux)/.test(resource); }, name: 'react', priority: 20, reuseExistingChunk: true, }, antd: { test: /[\\\\/]node_modules[\\\\/]antd/, name: 'antd', priority: 15, reuseExistingChunk: true, }, }, }, 简要解释上面这段配置 将node_modules共用部分打入vendor.js bundle中; 将react全家桶打入react.js bundle中; 如果项目依赖了antd,那么将antd打入单独的bundle中; 最后剩下的业务模块超过3次引用的公共模块,将自动提取公共块 优化效果做了这么多优化,下面是基于模块超过2.5k的辅导h5项目,构建耗时对比,感受一下效果 优化前:热构建需要40s -w642 优化后:只需要20s -w601 四、收敛配置集成最佳实践构建的配置和优化的工作并不小,将最佳实践收敛和集成为独立的模块,在不同项目中复用,可以大幅减少构建维护工作,以及后续升级优化工作难度。 IMWeb团队的项目目前也独立维护一套基于React技术栈的构建最佳实践工具a8k,在所有的项目中不会在看到复杂多样的webpack配置,以及各种花样的前置、后置脚本。各项目仅需要简单的关键配置即可快速接入该构建工具,享受其带来的开发体验提升,和构建性能提升。 五、其他经验关于node-sass用过node-sass的童鞋应该遇到过,安装node-sass遇到各种编译错误、二进制文件下载错误、甚至文件写入权限错误等等😟。也有各种骚操作解决这个问题,但终归不能一劳永逸。 于是就出现想通过postcss插件去兼容sass语法,虽然通过插件能够兼容部分语法,但是想要在已经有一定量的业务代码中,替换node-sass的风险是非常高的,本人亲自测试各种坑💣 当然也有其他途径解决这个问题,不仅让你使用完整的sass语法,同时也免去各种安装node-sass的问题,官方的sass-loader其实已经提供了dart-sass解析模块的支持具体参见文档,可能有人担心dart-sass的js模块性能不高,本人亲测在我们项目中2000+的模块中,dart-sass的编译性能并没有明显下降的感觉,同时我们使用使用了缓存能力,通常只变异哪些变化的资源。 具体的配置入下: 123456789101112{ loader: resolve('sass-loader'), options: { // 安装dart-sass模块:npm i -D sass implementation: require('sass'), includePaths: [ // 支持绝对路径查找 path.resolve(projectDir, 'src'), ], sourceMap, },}, node-sass 变量使用问题我在H5中发现很多这种语法的代码,但是实际上没有生效,构建后,并没有替换为变量的值。 -w615 编译后: -w937 解决方法如下: -w838 关于 postcss个人觉得postcss是css预处理器的未来,现在的postcss对于css就像babel对于JavaScript。postcss通过插件支持未来的css特性,于此同时你还可以自定义插件实现想要的特性。但其他的less、sass这种预处理器,就难以介入它的处理过程,只能按照它既定的规则处理。因此对于全新的项目建议直接使用postcss+postcss-preset-env 使用最新的css语法特性,同时以便于在未来浏览器全面支持相关特性后,快速接入支持。 💣如果你使用了css-loader的import能力,同时有使用了post-css-import插件的import能力,两个插件会存在冲突,不建议同时使用! 如果使用了postcss-custom-properties,需要注意在8.x版本中存在一个bug,无法解析如下语法:12345678910111213:root{ --green: var(--customGreen, #08cb6a); // 8.x无法正确处理该语法 --primary: var(--customPrimary, var(--green));}.test { background: color(var(--primary) shade(5%)); // 上面面这句将会被转换为如下代码,最终导致浏览器无法解析该语法 background: var(--green); background: var(--primary); // 我们期望转换为 background: #08cb6a;} 解决方法:禁用 postcss-preset-env 中的custom-properties,安装6.x版本的custom-properties,单独添加该插件。 关于缓存如果在开发模式下面启用了eslint-loader对jsx?文件校验,并且启动了其缓存能力,当修改eslint校验规则,你需要清理缓存文件并且重新启动构建,否则规则修改不会生效!如果使用a8k工具构建,可以使用k clean命令自动处理处理。 主域重试篇幅太长不详细介绍了,有兴趣的可以在这里看到相关源代码webpack-retry-load-plugin, 后续输入相关文章介绍如何实现CSS/JS同步异步代码重试","tags":[{"name":"webpack","slug":"webpack","permalink":"http://oedx.github.io/tags/webpack/"}]},{"title":"Cocos Creator 支持ETC1 + Alpha 纹理压缩","date":"2019-05-15T14:40:54.000Z","path":"2019/05/15/cocos-creator-support-etc1-alpha/","text":"ABCmouse是使用Cocos Creator(后面统称CC)开发的App,图片内存占用巨大,在Android低内存机器上容易造成OOM,纹理压缩后的图片可以直接在GPU加载渲染,减少占用内存。而本文基于CC V1.10.x版本做相应的分析及其改造,使项目支持ETC1(Ericsson Texture Compression)+Alpha纹理压缩 背景ABCmouse项目中用到的图片资源随着项目开发越来越多,手机Graphic内存占用越来越高,特别是在低端机器上容易Out Of Memory,而项目使用的CC版本是基于V1.10.x较低版本,官网已经升级到V2.0.9,版本差异大升级困难,故而需要在原版上进行支持纹理压缩。 what、why、how ETC纹理压缩What:ETC是把4x4的像素块压缩成一个64或128位的数据块,是有损压缩,移动平台游戏比较常用的压缩方式之一(iOS设备中支持的是PVR压缩和ETC2,在Android中支持的是ETC压缩)。而ETC又分为ETC1和ETC2,ETC2是向下兼容ETC1,对RGB的压缩质量更好,并且支持透明通道,当然对软件硬件也是有一定的要求。而各种Android设备基本都支持ETC1,ETC1不支持透明通道。 ETC1、ETC2对比如下: 类型 是否支持Alpha 支持OpenGLES版本 Android版本支持 压缩率 压缩质量 ETC1 No OpenGLES 2.0+ Android 2.2 (API level 8) and higher 6:1 好 ETC2 Yes OpenGLES 3.0+ Android 4.3 (API level 18) and higher 6:1 较好 参考OpenGL ES 版本Android占比分布:https://developer.android.com/about/dashboards/index.html#Screenshttps://developer.android.com/guide/topics/graphics/opengl.html 硬件方面:虽说ETC2支持Android版本4.3+,但并非所有的4.3机器都支持ETC2,这取决于手机厂商定制的GPU型号是否支持。 Why:而使用纹理压缩有什么好处呢?纹理压缩后的图片,不经过CPU解码,直接使用GPU加载渲染,大大提高了加载速度。为此,很多游戏App采用ETC1+Alpha来解决ETC1不支持透明通道的问题。 How:那么,如何生成ETC1+Alpha呢?首先移动端是无法直接在移动端生成etc,需要额外的制作工具。这里推荐使用Mali Texture Compression Tool,这个工具可以生成ETC1和带透明通道的ETC1,下载地址:https://developer.arm.com/tools-and-software/graphics-and-gaming/graphics-development-tools/mali-texture-compression-tool/downloads 实现方案基于我们项目中,目前Android配置的minSdkVersion是19,那么是否可以考虑直接使用ETC2呢?抱着测试的心态,先使用上面介绍的Mali工具,使用以下命令直接将图片压缩为ETC2格式:1etcpack srcfile outfile -c etc2 -f RGBA 具体CC编译后的Android包如何加载ETC2可以参看文章:https://forum.cocos.com/t/cocos-etc2/49061 这里不再详述。然而前面已经提到的有些低端机器不支持ETC2的加载,这导致了渲染黑屏。通过CC引擎检测是否支持ETC2 log我们可以看到,该手机硬件不支持,具体log如下:12345678910111213141516171819202122D/cocos2d-x: { gl.supports_OES_packed_depth_stencil: true gl.supports_vertex_array_object: true gl.supports_BGRA8888: false cocos2d.x.version: Cocos2d-x-lite v1.8.2 gl.supports_discard_framebuffer: true cocos2d.x.compiled_with_profiler: false gl.supports_PVRTC: false cocos2d.x.build_type: DEBUG gl.renderer: Adreno (TM) 530 gl.supports_OES_depth24: true gl.supports_ETC1: true gl.supports_OES_map_buffer: false cocos2d.x.compiled_with_gl_state_cache: true gl.version: OpenGL ES 3.2 V@313.0 (GIT@984b9a6, Ibe1bf21abc) (Date:06/04/18) gl.supports_NPOT: true gl.supports_ETC2: false gl.max_texture_units: 96 gl.vendor: Qualcomm gl.max_texture_size: 16384 }cocos2d-x: cocos2d: Hardware ETC2 decoder not support. 考虑到需要兼容部分低端机型,这里不得不放弃ETC2的使用,转而尝试使用ETC1+Alpha。同样的,使用Mali工具对图片进行纹理压缩命令如下:(使用命令生成出来的纹理上半部分是原始图片(无alpha信息),下半部分是alpha信息图片)1etcpack srcfile outfile -c etc -aa 原始图 ETC1+Alpha纹理压缩后预览图 可以看到纹理压缩后的图片高度是原来图片高度的2倍,那么最关键的就是如何让其渲染成原始图片。答案是使用Shader对其进行渲染。首先了解一下CCSprite、CCSpriteFrame、CCTexture2D之间的关系(图来自CC官网文档): assets 从图中可以看到,我们肉眼看到的是CCSprite渲染出来的图片,CCSpriteFrame为精灵的某一帧,CCTexture2D为图片纹理数据,也对应上从Cocos js加载图片到sprite中代码:1234567properties: { sprite: cc.Sprite,}...cc.loader.load(path, function (err, texture) { this.sprite.spriteFrame = new cc.SpriteFrame(texture);}); 了解了以上概念后,纹理压缩后出来的文件就是对应的CCTexture2D,如何让其显示到CCSprite上就是我们要做的处理。我们先看一下整体的实现流程图(从构建–>纹理压缩–>打包apk–>图片渲染): 流程图 开发过程遇到的问题如果直接使用CC v1.10.x版本的代码直接加载etc1+alpha文件,那么将出现各种问题: 图片移位 渲染黑块 图片遮罩不生效 自定义Shader不生效 cocos js获取Texture的height变成实际显示高度的2倍 ✔️ cc.loader.load动态加载的图片将出现移位解决的最关键一步是获取Texture2D的pixelFormat为ETC格式时需要update SpriteFrame的几个属性,具体实现需要修改c++层的CCSpriteFrame.cpp进行适配:12345678910111213141516171819void SpriteFrame::setTexture(Texture2D * texture){ if( _texture != texture ) { CC_SAFE_RELEASE(_texture); CC_SAFE_RETAIN(texture); if(texture->getPixelFormat() == Texture2D::PixelFormat::ETC){ int texHigh = texture->getPixelsHigh(); if(texHigh == _rect.size.height){ _rect.size.height *= 0.5; _rectInPixels = CC_RECT_POINTS_TO_PIXELS(_rect); Size size = CC_SIZE_POINTS_TO_PIXELS(texture->getContentSize()); size.height *= 0.5; _originalSizeInPixels = size; _originalSize = CC_SIZE_PIXELS_TO_POINTS( _originalSizeInPixels); } } _texture = texture; }} 可以看到我们将_rect.size.height和contentSize.height减半并更新_rect、_rectInPixels、_originalSizeInPixels、_originalSiz属性 ✔️ 渲染黑块解决渲染黑块,使用shader对etc纹理进行渲染,将遮罩部分作为Alpha值加到原图上顶点着色器 ccShader_PositionTexture_Ect1Alpha.vert:1234567891011121314const char* ccPositionTexture_Ect1Alpha_vert = STRINGIFY(attribute vec4 a_position;attribute vec2 a_texCoord;attribute vec4 a_color;varying vec4 v_fragmentColor;varying vec2 v_texCoord;varying vec2 v_alphaCoord;void main(){ gl_Position = CC_PMatrix * a_position; v_fragmentColor = a_color; v_texCoord = a_texCoord;}); 片段着色器 ccShader_PositionTexture_Ect1Alpha.frag:12345678910111213141516const char* ccPositionTexture_Ect1Alpha_frag = STRINGIFY(\\n#ifdef GL_ES\\nprecision lowp float;\\n#endif\\nvarying vec4 v_fragmentColor;varying vec2 v_texCoord;varying vec2 v_alphaCoord;void main(){ vec4 v4Colour = texture2D(CC_Texture0, v_texCoord); v4Colour.a = texture2D(CC_Texture0, vec2(0.0, 0.5) + v_texCoord).r; //v4Colour.rgb *= v4Colour.a;//Premultiply with Alpha channel gl_FragColor = v_fragmentColor * v4Colour;}); 具体的shader语法可参考:https://learnopengl-cn.github.io/01%20Getting%20started/04%20Hello%20Triangle/#_3 ✔️ 图片遮罩不生效 图片遮罩 CC上预览是可以,但由于mask上设置的SpriteFrame经过纹理压缩后,在app上就展示不出mask效果。需要找到mask设置SpriteFrame对应的c++层逻辑,对应CCClippingNode.cpp如下方法:123456789101112131415161718192021222324252627282930313233void ClippingNode::visit(Renderer *renderer, const Mat4 &parentTransform, uint32_t parentFlags){ ……省略部分代码 auto alphaThreshold = this->getAlphaThreshold(); if (alphaThreshold < 1) {#if CC_CLIPPING_NODE_OPENGLES // since glAlphaTest do not exists in OES, use a shader that writes // pixel only if greater than an alpha threshold // GLProgram *program = GLProgramCache::getInstance()->getGLProgram(GLProgram::SHADER_NAME_POSITION_TEXTURE_ALPHA_TEST_NO_MV); if(_stencil != nullptr){ if (auto scale9sp = dynamic_cast<creator::Scale9SpriteV2*>(_stencil)){ cocos2d::SpriteFrame* spriteFrame = scale9sp->getSpriteFrame(); if(spriteFrame && spriteFrame->getTexture() && spriteFrame->getTexture()->getPixelFormat() == Texture2D::PixelFormat::ETC){ program = GLProgramCache::getInstance()->getGLProgram(GLProgram::SHADER_NAME_POSITION_TEXTURE_ETC_ALPHA_TEST_NO_MV); } } } GLint alphaValueLocation = glGetUniformLocation(program->getProgram(), GLProgram::UNIFORM_NAME_ALPHA_TEST_VALUE); // set our alphaThreshold program->use(); program->setUniformLocationWith1f(alphaValueLocation, alphaThreshold); // we need to recursively apply this shader to all the nodes in the stencil node // FIXME: we should have a way to apply shader to all nodes without having to do this setProgram(_stencil, program);#endif } ……省略部分代码} 找到alphaThreshold小于1的地方,要应用GLProgram之前先判断PixelFormat格式如果是etc则重新设置shader,SHADER_NAME_POSITION_TEXTURE_ETC_ALPHA_TEST_NO_MV是我重新设置的shader,区别于SHADER_NAME_POSITION_TEXTURE_ALPHA_TEST_NO_MV只是关键的一行代码:123456789101112131415161718192021222324const char* ccPositionEtcTextureColorAlphaTest_frag = STRINGIFY(\\n#ifdef GL_ES\\nprecision lowp float;\\n#endif\\nvarying vec4 v_fragmentColor;varying vec2 v_texCoord;uniform float CC_alpha_value;void main(){ vec4 texColor = texture2D(CC_Texture0, v_texCoord); texColor.a = texture2D(CC_Texture0, vec2(0.0, 0.5) + v_texCoord).r;//关键的一行:设置Alpha层叠加到原图上\\n// mimic: glAlphaFunc(GL_GREATER)\\n// pass if ( incoming_pixel >= CC_alpha_value ) => fail if incoming_pixel < CC_alpha_value\\n if ( texColor.a <= CC_alpha_value ) discard; gl_FragColor = texColor * v_fragmentColor;}); ✔️ 自定义Shader不生效 ABCmouse项目中的bookplayer使用了自定义Shader,加了纹理压缩后,发现翻书shader效果失效了,而翻书用的图片是网络图片(没有经过纹理压缩),理论上跟etc纹理没关系。 后面定位到是CCScale9Sprite.cpp下setSpriteFrame当spriteFrame为空时重新设置了默认shader(SHADER_NAME_POSITION_TEXTURE_COLOR_NO_MVP)导致,而bookplayer刚好就是有设置sprite.spriteFrame = null;的情况,解决方法去掉设置默认shader即可123456789101112131415bool Scale9SpriteV2::setSpriteFrame(cocos2d::SpriteFrame* spriteFrame){ if(this->_spriteFrame == nullptr){ if (spriteFrame && spriteFrame->getTexture()) { Texture2D::PixelFormat pixelFormat = spriteFrame->getTexture()->getPixelFormat(); if (pixelFormat == Texture2D::PixelFormat::ETC) { this->setGLProgramState(GLProgramState::getOrCreateWithGLProgramName(GLProgram::SHADER_NAME_POSITION_TEXTURE_ETC1ALPHA)); } else{//自己添加的下面这行导致shader被覆盖,去掉即可 this->setGLProgramState(GLProgramState::getOrCreateWithGLProgramName(GLProgram::SHADER_NAME_POSITION_TEXTURE_COLOR_NO_MVP)); } } } ......忽略其他代码 return true;} 那么还可以思考的是,要是etc纹理做自定义shader应该如何处理呢?项目中暂时没有这种暂时没支持,编码思路是判断是否有自定义纹理,有则使用自定义,没有则用etc shader ✔️ 经过ETC纹理压缩后,cocos js获取Texture的height变成实际显示高度的2倍解决方法是在cc.Texture2D新增获取真正height的方法,如下:123456cc.Texture2D.prototype.getWrapPixelHeight = function(){//Android本地图片使用etc+alpha纹理压缩,height、getPixelHeight()、pixelHeight是显示height的两倍,请使用这个wrap方法获取修复 if(this.getPixelFormat() == 14){//etc+alpha 高度减半处理 return this.height * 0.5; } return this.height; }; 纹理压缩白名单由于etc是有损压缩,对于一些设计师图片有质量要求(特别是alpha透明度有要求)的需要添加到白名单里,不进行压缩。 目前白名单主要有:AutoAtlas和骨骼动画、压缩后alpha有问题的图片 优化后的内存效果 Graphics:图形缓冲区队列向屏幕显示像素(包括 GL surfaces, GL textures等等)所使用的内存 使用Memory Profiler分析内存占用情况,在同一场景gc后稳定内存,然后分别加载png图片和etc图片,对比内存如下: png内存占用 etc内存占用 类型 加载图片前 加载图片后 占用内存 png 176.3M 182M 5.7M etc 195.5M 197.3M 1.8M 附加参考: CC论坛:cocos实现对ETC2的支持:https://forum.cocos.com/t/cocos-etc2/49061CC论坛:Creator使用压缩纹理:https://forum.cocos.com/t/creator/47206cocos2d中添加自己的shader教程:http://www.cocoachina.com/bbs/read.php?tid=220630OpenGL着色器语言(GLSL):https://learnopengl-cn.github.io/01%20Getting%20started/05%20Shaders/关于cocos2d-x对etc1图片支持的分析:https://blog.csdn.net/langresser_king/article/details/9339313Mali-Texture-Compression工具:https://developer.arm.com/tools-and-software/graphics-and-gaming/graphics-development-tools/mali-texture-compression-tool/downloads 以上如有错误疏漏,烦请指正","tags":[{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"},{"name":"纹理压缩","slug":"纹理压缩","permalink":"http://oedx.github.io/tags/纹理压缩/"}]},{"title":"CocosCreator 引擎资源加载与释放原理简析","date":"2019-03-14T17:01:35.000Z","path":"2019/03/14/brief-analysis-of-the-cocoscreator-engine-resource-loading-and-release-principle/","text":"图片取自 Zoommy 本文主要内容如下: 资源加载与释放部分代码所在 调试、修改引擎源码的方法 资源加载与释放原理简析 如要了解 CocosCreator 引擎资源加载与释放的原理,调试、修改引擎代码有助于对其原理进行理解。因此文中会先介绍 CocosCreator 引擎各部分及其文件夹,然后介绍调试、修改引擎源码的方法,最后对其原理进行分析。 如果你对阅读、调试修改源码不感兴趣,可直接跳转到第三部分阅读。如果你对此主题不感兴趣,可直接关闭网页。 本文基于 CocosCreator 1.10.2,另外 2.0.8 版本关于资源加载释放部分改动不大,也可适用 前言Cocos Creator 的引擎部分包括 JavaScript、Cocos2d-x-lite 和 adapter 三个部分,各部分对应源码在(Mac 版): JavaScript:CocosCreator.app/Resource/engine(JS 引擎) Cocos2d-x-lite:CocosCreator.app/Resource/cocos2d-x(Cococ2d-x 引擎) adapter:CocosCreator.app/Resource/builtin/ 其中 engine 文件夹下代码部分包含了引擎的 JS 层逻辑,而引擎的资源加载与释放部分代码就处于此文件夹中,路径为 cocos2d/core/load-pipeline/,主要涉及 pipeline.js、loading-items.js、CCLoader.js、loader.js、uuid-loader.js 等文件。 另外,需要了解的是 CocosCreator 引擎中资源是有依赖关系的,比如 SpriteAtlas 资源的加载会依赖于多个 SpriteFrame 资源的加载,而 SpriteFrame 资源依赖于 Texture2D 资源。 下边对调试、修改引擎代码做简单介绍。 调试、修改引擎代码 找到 CocosCreator 的 JavaScript 引擎所在目录 CocosCreator.app/Resource/engine,将该文件夹复制到其他地方,我们将对复制后的代码进行调试和修改。然后在 CocosCreator 的项目设置中修改 JavaScript 引擎路径为复制后的路径。如果要调试 Cocos2d-x 部分,修改对应文件夹即可。如下图: 定义 JavaScript 引擎路径 运行以下命令安装编译依赖 123456# 在命令行中进入引擎路径cd [engine_path]/engine# 安装 gulp 构建工具npm install -g gulp# 安装依赖的模块npm install 现在可以打开 engine 文件夹,对 JS 引擎部分进行修改。修改后,在该文件夹下运行 gulp build 命令即可编译修改的部分,然后刷新 CocosCreator 预览的网页即可 至于调试其源码,可以直接在 Chrome 开发者工具中 Cmd + o (Mac 快捷键)呼出搜索框,输入并打开你需要调试的文件,然后即可打断点进行调试 CocosCreator 引擎资源加载与释放简析引擎资源加载与释放源码路径为 engine/cocos2d/core/load-pipeline/,主要关注 pipeline.js、loading-items.js、CCLoader.js、loader.js、uuid-loader.js 这几个文件。 其中资源加载涉及 CCLoader、pipeline、loading-items、loader、uuid-loader 等多个类,而资源释放则主要是 CCLoader 中的 release 方法。 资源加载概括来讲,CCLoader 是供上层直接使用加载、释放资源的类,整合、封装了 pipeline、loading-items、loader 等类;pipeline 包含多个 pipe,这里的 pipe 是指实现了加载资源的单位(如 loader),pipeline 对资源的处理最终是调用 pipe.handle 实现的,pipeline 自身实现的是资源缓存和让资源依次流过管道,即每个资源依次经过每个 pipe 的处理;loading-items 配合 pipeline,实现了加载状态维护、依赖资源入队等内容;asset-loader、downloader、loader 则实现了资源加载具体功能,不同的加载器负责不同资源的加载。 简化来看,一次资源的加载流程底层的调用函数如下: cc.loader.loadRes() CCLoader.load() loading-items.append pipeline.flowIn pipeline.flow pipe.handle(pipe 对应于 asset-loader、downloader、loader) 加载完成,进行回调 加载流程中其实还有一种情况需要注意:loader 其实会根据资源类型将加载任务分发给不同的类,如 uuid 类型会交给 uuid-loader。uuid-loader 加载时会加载该资源的依赖资源,其过程为: uuid-loader.loadUuid uuid-loader.loadDepends pipeline.flowInDeps loading-items.append 后续流程和正常流程一致 以下会根据加载流程分析一下是如何加载资源的。 CCLoaderCCLoader 继承自 pipeline,是用户可以直接调用来加载、释放资源的类,它对 pipeline、loading-items、loader 进行了整合封装,并提供了 load、loadRes、loadResDir、release、releaseRes 等方法供用户使用。资源加载相关的便是 load、loadRes 等方法,而所有方法最终是调用 load 方法来进行资源加载的。load 方法如下: 123456789101112131415161718192021222324proto.load = function(resources, progressCallback, completeCallback) { // 省略部分代码 _sharedResources.length = 0; for (var i = 0; i < resources.length; ++i) { var resource = resources[i]; // 省略部分代码 var res = getResWithUrl(resource); if (!res.url && !res.uuid) continue; var item = this._cache[res.url]; _sharedResources.push(item || res); } var queue = LoadingItems.create(this, progressCallback, function (errors, items) { callInNextTick(function () { // 省略部分代码 }); }); LoadingItems.initQueueDeps(queue); queue.append(_sharedResources); _sharedResources.length = 0; 可以看到 load 方法中,所做的就是创建一个 LoadingItem,然后对其进行初始化,之后调用其 append 方法。这里 LoadingItem 简单来看就是对待加载资源的一层封装。下边介绍 loading-items。 loading-itemsloading-items 主要是完善了每个资源对象的属性,包括资源内容、url 地址等,同时维护每个资源的加载状态、依赖关系等。以下是 append 方法代码。 1234567891011121314151617181920212223242526272829303132333435proto.append = function (urlList, owner) { // 省略代码 this._appending = true; var accepted = [], i, url, item; for (i = 0; i < urlList.length; ++i) { // 省略代码,主要是对每个资源的依赖关系、循环依赖、是否完成等进行处理 // Queue new items if (isIdValid(url)) { item = createItem(url, this._id); var key = item.id; // No duplicated url if (!this.map[key]) { this.map[key] = item; this.totalCount++; // Register item deps for circle reference check owner && owner.deps.push(item); LoadingItems.registerQueueDep(owner || this._id, key); accepted.push(item); // console.log('+++++ Appended ' + item.id); } } } this._appending = false; // Manually complete if (this.completedCount === this.totalCount) { this.allComplete(); } else { this._pipeline.flowIn(accepted); } return accepted;}; 从代码可以看出,append 所做的是:先对资源列表 urlList 里每一个元素做字段完善(create)、依赖处理(registerQueueDep),然后调用 pipeline.flowIn 将所有未加载完成的资源放入 pipeline 之中。下边介绍 pipeline。 pipelinepipeline 包含多个 pipe,pipe 存储在 _pipes 数组中,而且数组中的 nextPipe 赋值给了 lastPipe.next,以此将 pipe 链接成单向链表结构。这里的 pipe 是指实现了加载资源的单位(如 loader),pipeline 对资源的处理最终是调用 pipe.handle 实现的,pipeline 自身实现的是资源缓存和让资源依次流过管道,即每个资源依次经过每个 pipe 的处理。在 CCLoader 初始化 pipeline 时,为它添加了 AssetLoader、Downloader、Loader 三个 pipe,每个资源都会依次经过这三个 pipe 的处理。pipeline 中的 _cache 数组是对每个资源的缓存。 以下是其 flowIn 方法: 12345678910111213141516171819proto.flowIn = function (items) { var i, pipe = this._pipes[0], item; if (pipe) { // Cache all items first, in case synchronous loading flow same item repeatly for (i = 0; i < items.length; i++) { item = items[i]; this._cache[item.id] = item; } for (i = 0; i < items.length; i++) { item = items[i]; flow(pipe, item); } } else { for (i = 0; i < items.length; i++) { this.flowOut(items[i]); } }}; flowIn 方法所做其实就是:先将每个资源 item 缓存到 _cache 中,然后对每个 item 调用 flow 方法。这里要注意的是 flow 的 pipe 参数为 _pipes[0],即先将资源交给 pipeline 的第一个 pipe。以下是 flow 方法: 1234567891011121314151617181920212223242526272829303132333435363738394041424344function flow (pipe, item) { var pipeId = pipe.id; var itemState = item.states[pipeId]; var next = pipe.next; var pipeline = pipe.pipeline; if (item.error || itemState === ItemState.WORKING || itemState === ItemState.ERROR) { return; } else if (itemState === ItemState.COMPLETE) { if (next) { flow(next, item); } else { pipeline.flowOut(item); } } else { item.states[pipeId] = ItemState.WORKING; // Pass async callback in case it's a async call var result = pipe.handle(item, function (err, result) { // 省略代码 }); // If result exists (not undefined, null is ok), then we go with sync call flow if (result instanceof Error) { item.error = result; item.states[pipeId] = ItemState.ERROR; pipeline.flowOut(item); } else if (result !== undefined) { // Result can be null, then it means no result for this pipe if (result !== null) { item.content = result; } item.states[pipeId] = ItemState.COMPLETE; if (next) { flow(next, item); } else { pipeline.flowOut(item); } } }} flow 方法中对资源的处理分为三部分: 如果资源状态为 ERROR,则直接返回 如果资源状态为 COMPLETE 已完成,如果后续还有 pipe 就将其交给下一个 pipe 处理 如果资源状态为空,说明此资源从未进行加载,则调用 pipe.handle 方法对其进行加载。加载完成后,先将加载结果交给 item,即item.content = result,然后会对其加载状态 states 进行修改,最后如果后续还有 pipe 就将其交给后续 pipe 处理。 接下来再来看一下 pipe.handle 方法。从以下代码可以看出, CCLoader 初始化时,提供了三个 pipe:asset-loader、downloader、loader。实际上 pipe.handle 调用的其实是 assetLoader、downloader、loader 的 handle 方法,下边以 loader 为例进行介绍。 123456789101112function CCLoader () { var assetLoader = new AssetLoader(); var downloader = new Downloader(); var loader = new Loader(); Pipeline.call(this, [ assetLoader, downloader, loader ]); // 省略代码} loaderloader 是 CCLoader 中添加到 pipeline 的 pipe 之一,其作用就是加载资源,pipeline 会直接调用其 handle 方法来加载资源。类似的类有 asset-loader、downloader。而 loader 相对特殊是它其中又会根据资源类型不同,调用不同的类去实现加载功能。比如 uuid 类型会调用 uuid-loader 来加载。这里提到的 uuid-loader 在加载资源的时候,会去判断资源是否有依赖资源,如果有会将依赖资源添加到 pipeline 中,进而对依赖资源进行加载。 loader 的部分代码如下: 12345678910111213141516171819202122var defaultMap = { // Images 'png' : loadImage, 'jpg' : loadImage, 'bmp' : loadImage, // 省略代码 'uuid' : loadUuid, 'prefab' : loadUuid, 'fire' : loadUuid, 'scene' : loadUuid, 'default' : loadNothing};// 省略代码Loader.prototype.handle = function (item, callback) { var loadFunc = this.extMap[item.type] || this.extMap['default']; return loadFunc.call(this, item, callback);}; 可以看出 loader 会根据资源类型不同,进而调用对应的类去实现加载功能。如 uuid 类型资源,调用的是 uuid-loader 的 loadUuid 方法。而至于 loadUuid 方法中,则做了 deserialize 资源、解析处理依赖资源等。 资源释放CCLoader 对于资源释放提供了 release、releaseRes、releaseAsset、releaseResDir、releaseAll 方法,但实际上所有方法最终是调用 release 方法来实现资源释放的,在 release 调用之前所做的就是获取资源对应的 uuid,然后调用 release 方法。以下是 release 方法: 1234567891011121314151617181920212223242526proto.release = function (asset) { if (Array.isArray(asset)) { for (let i = 0; i < asset.length; i++) { var key = asset[i]; this.release(key); } } else if (asset) { var id = this._getReferenceKey(asset); var item = this.getItem(id); if (item) { var removed = this.removeItem(id); asset = item.content; if (cc.Class.isInstanceOf(asset, cc.Asset)) { if (asset.nativeUrl) { this.release(asset.nativeUrl); // uncache loading item of native asset } asset.destroy(); } if (CC_DEBUG && removed) { this._releasedAssetChecker_DEBUG.setReleased(item, id); } } }}; 从代码中可以看到,release 分为两部分: 如果资源是数组,则对数组中的每个元素调用 release 第二部分是释放资源的关键,所做的工作可以简化如下: 获取资源在 pipeline._cache 中的缓存 item,并在 _cache 中移除该 item 拿到资源的实际内容,即 item.content,这也是 loader 加载出来的资源结果。如果 content 是 Asset 类型,则调用其 destroy 方法进行释放,同时释放其依赖的 nativeUrl 资源 如果是 debug 模式,则会在 released-asset-checker 中标记该资源为已释放 注: 这里的 released-asset-checker 是一个辅助类,其中一个作用就是检测已释放资源是否仍被其他资源引用 release 方法其实仍存在缺陷,缺陷之一是无法释放 SpriteAtlas 所依赖的 SpriteFrame 资源,在释放后 SpriteFrame 资源仍存在与 pipeline._cache 中。这导致释放后再次加载同一个 SpriteAtlas 资源的加载不完整问题,该问题的表现是第二次加载 SpriteAtlas 后播放帧动画黑块现象。原因是:加载到 SpriteAtlas 依赖的 SpriteFrame 资源部分时,根据 pipeline.flow 方法代码会发现由于此时 SpriteFrame.states = { "AssetLoader": 2, "Downloader": 2, "loader": 2},这里 2 代表加载状态 COMPLETE,就是说该资源在所有 pipe 的状态均是已加载完成,资源直接通过了三个管道,最后执行 flowOut 方法,没有继续加载 SpriteFrame 所依赖的 Texture2D 资源,导致显示黑块。 参考文章 引擎定制工作流程 CocosCreator API 文档 本文图片使用 Alfred Workflow:SmartPic 一键上传获取链接,点击查看 SmartPic","tags":[{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"}]},{"title":"精确定位 Cocos 线上报错","date":"2019-01-14T17:01:35.000Z","path":"2019/01/14/precise-positioning-cocos-online-error/","text":"图片源自 Zoommy 在 Cocos-JS 的项目中,编译时往往采用 加密 + 压缩 的方式。最终 CocosCreator 编译出的代码文件都是无法直接阅读的,代码文件有 jsb_polyfill.jsc、project.jsc、setting.jsc。强行打开如下图: 乱码 应用上线后,如有线上 JS 报错,很多情况也是无法直接定位到问题的,错误中指明的只有在 jsb_polyfill.js 文件的多少行有问题,而无法还原到实际的代码文件。比如: 线上报错 对于此类问题,可以通过 Cocos-JS 加解密工具来帮助我们更加精确的定位问题,其代码地址为 cocos-jsc-endecryptor。 使用 Cocos-JS 加解密工具来定位问题代码行 这里以线下 Cocos 编译的 project.jsc 为例 把上述工具的代码拉到本地 终端执行如下命令即可将加密编译出的代码文件反编译\b出来,以\b project.jsc 文件为例,你需要替换括号中内容为你实际的路径和密钥: 1{PATH}/cocos-jsc-endecryptor/edc.py decrypt -k {COCOS_PROJECT_ENCRYT_KEY} -p {COCOS_PROJECT_PATH}/build/jsb-default/src/project.jsc 你也可以在 VSCode 中配置一项任务,之后直接在 VSCode 中运行该任务即可编译出 project.jsc 来定位问题: 123456789{ \"label\": \"decryABCmouse\", \"group\": { \"kind\": \"build\", \"isDefault\": true }, \"type\": \"shell\", \"command\": \"{PATH}/cocos-jsc-endecryptor/edc.py decrypt -k {COCOS_PROJECT_ENCRYT_KEY} -p {COCOS_PROJECT_PATH}/build/jsb-default/src/project.jsc ; open {COCOS_PROJECT_PATH}/decryptOutput\"} 注:VS Code 中配置任务可以在 Cmd + Shift + P 呼出命令面板后,输入 tasks configure task,然后根据提示新增任务,之后可以在命令面板输入 tasks run task,运行刚刚创建的命令。 打开 project.jsc \b解密后的文件 decry.js,使用 Ctrl + G 快捷键呼出快速跳转代码行窗口,输入线上报错中的行数,如上图报错中\b的 16661。即可快速跳转到出错行对应的代码: 然后即可看到具体的代码,而非乱码。之后即可根据这里更加具体的代码来定位问题: 注:这里以线下 Cocos 编译的 project.jsc 为例,你也可以反编译对应的 jsb_polyfill.jsc 文件。如果是编译线上的文件,可以在对应的 apk 中找到。","tags":[{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"}]},{"title":"适用于 Cocos 的 JSC 加解密工具","date":"2018-09-14T17:01:35.000Z","path":"2018/09/14/jsc-encryption-and-decryption-tools-for-cocos/","text":"脚本地址:Github简介此脚本用于CocosCreator加密编译后 jsc 文件解密为 js 文件和 js 文件加密为 jsc 文件。 CocosCreator构建时,是否勾选Zip压缩选项决定了使用脚本的参数不同。在CocosCreator的构建面板下图的位置中,查看加密密钥和是否开启Zip压缩。 此脚本在 macOS High Sierra 10.13.6 系统,Python 2.7 下运行正常,其他环境未测试 使用说明命令行使用: 如果使用加密功能,第二个参数设置为 encrypt;如果使用解密功能,第二个参数设置为 decrypt。此参数为必选参数 如需设置加密密钥,添加 –key 或 -k 参数,并跟上加密密钥字符串。如不设置,会在命令行中提示输入 如需设置为非压缩方案,添加 –nozip 或 -n 参数,并设置为 true。如不设置,默认为压缩方案 非压缩方案是指Cocos编译时没有勾选“Zip 压缩”选项 找到CocosCreator编译出来的 .jsc 文件,一般在工程目录下 build/jsb-default/src 文件夹下。你可以在\b脚本运行时,根据提示输入文件的路径来指定对应文件。也可以添加 –path 或 -p 参数,设置为文件路径。如不设置,会在命令行中提示输入 运行脚本即可 encrypt:解密后文件路径为 \bdecryptOutput/decrypt.js decrypt: 加密后文件路径为 encryptOutput/projectChanged.jsc 举例: ./edc.py encrypt –key yourkey –nozip true ./edc.py decrypt –nozip true ./edc.py decrypt 其他Python脚本中引用: 下载edc.py文件放到你的脚本目录下,通过 import edc 进行导入 直接调用 edc.decrypt(is_zip, key, jsc_path) 或 edc.encrypt(is_zip, key, js_path) 即可,可参考 edcExample.py 文件\b 如果是非交互式脚本,请务必在调用方法时传入有效的参数,并保证其正确性 参数说明 参数名 缩写 是否必须 默认值 encrypt/decrypt 无 是 - –key -k 否 - –nozip -n 否 false –path -p 否 - 参考文章 形同虚设的cocos默认加密 cocos2dx lua 反编译","tags":[{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"}]},{"title":"基于 Cocos 的高性能跨平台开发方案","date":"2018-07-07T17:01:35.000Z","path":"2018/07/07/cocos-based-high-performance-cross-platform-app-developing/","text":"大概从去年九月份开始,我们选择使用Cocos来作为我们一款产品 ABCmouse 的跨平台应用开发方案,在这个过程中,我们做了一系列的优化,也踩了一些坑,本文将对这个过程做一个回顾和总结。 本文的内容主要分三块来讲。首先简单介绍一下项目背景,接下来具体介绍下我们的实践过程,分享一些经验。最后给出新的开发方案和以前的效果对比。 项目背景首先介绍一下我们的产品,ABCmouse 是美国知名的儿童英语在线学习领导品牌,在美国有超过百万家庭在使用,也获得了7万多个教师的推荐。 ABCmouse 这个应用采用的是典型的 Hybrid App 跨平台开发方案,里头基本全是 H5 的页面。 ABCmouse是一款 Hybrid App Hybrid App 最大的问题就是性能问题,用户经常会在页面加载上等待非常多时间。 我们统计了 ABCmouse 各个场景的平均加载耗时,发现平均都要花费大约三到四秒的时间。漫长的等待时间也对用户的学习积极性带来影响。 ABCmouse启动耗时统计 从去年九月份开始,团队与 ABCmouse 的研发公司 Age of Learning 公司开展了战略合作,我们希望能够开发出一款针对中国儿童的英语学习应用——我们称之为 ABCmouse 腾讯版。我们希望它能提供更符合中国儿童使用习惯的学习路径,并在里头融入腾讯的社交元素,从而带动儿童外语学习的积极性。 ABCmouse腾讯版 从技术上,我们希望新版的 ABCmouse 能够在表现力、性能、效率和社交四大方面都能有更好的表现(这里的表现力指的是产品的界面和交互,能够做到更吸引中国的小朋友)。 通过初期技术预研后,我们决定使用 Cocos 来改造这个项目: 跨平台。Cocos 支持使用同一套代码构建生成 Web、iOS、Android 等几个端,最新的版本还支持发布到微信小游戏、Facebook Instant Games 和 QQ 玩一玩; 性能。Cocos 的原理是在 Activity 中绘制一个 OpenGL 的 SurfaceView ,并由其完成页面的渲染的。与基于 WebView 渲染的 Hybrid 应用相比,Cocos 的渲染速度更快,性能更好。 效率。借助可视化的 Cocos Creator 工具,界面的开发和资源的管理非常便捷,设计团队也可以参与进来设计界面和动效,提升开发效率。 表现力。ABCmouse 中包含了很多诸如游戏、画图、音乐等带游戏和娱乐性质的场景,而 Cocos 本身是为游戏开发设计的,更适合用在我们的产品中。 具体实践在具体实践这一块,我准备分成架构篇、甜头篇、踩坑篇、优化篇四个部分来介绍。 架构篇一图胜千言。我们整个系统架构可以用这张图来概括。 新版ABCmouse的应用架构 我们自底向上看,最底层是 native 层,Cocos2d-x 开发框架,在这一层提供了对 JavaScriptCore、SpiderMonkey、V8、ChakraCore 等多种可选的 JS 执行引擎的封装。在这基础上又架设了一层 JSB ,主要起到桥接作用。我们的应用也在底层封装了多种基础能力,包括支持直出的webview、自定义的视频播放器、音频播放器、支付、推送等。 再往上是 JS 层,在这一层 Cocos 提供了丰富的开发组件和 API,我们也扩展了多种组件,包括一些通用的UI组件、一个多端通用的音频播放器、一个带缓存和内存回收功能的图片加载器、常驻节点、上报、日志等组件。有些组件是依赖 native 层的。 Cocos 层和 Native 层就通过 callStaticMethod 和 evalString 来完成互相调用。 有了这些基础后,再往上则可以开展具体的场景开发了。 为了帮助大家更好地理解 Cocos 的跨平台原理,我们可以拿 Cocos 的渲染原理和 React Native 做一个对比。 Cocos 的渲染原理是在 UI 线程将场景文件理解成场景树,然后交给 GL 线程渲染。也就是说,用户看到的大部分场景都是使用 OpenGL 或者 WebGL 绘制的,即使在不同的平台,也能够有完全相同的表现。 而 React Native 的渲染原理是将 JS/JSX 理解成 Virtual DOM,然后调用各自平台的 Widget 。由于不同的平台,底层的 Widget 表现是不同的,因此使用上可能会存在差异。这也是 React Native 为人诟病的一点。 甜头篇采用 Cocos 作为我们的跨平台开发框架后,我们尝到了不少甜头。 首先是跨平台带来的便利。我们使用一套代码可以生成到安卓、iOS、Web、微信小游戏等多种平台,并且在多个端达到了高度一致的体验。在 React Native 上经常遇到的 UI 体验不一致的问题,在 Cocos 开发中基本没有遇到过。 由于Cocos支持构建小游戏版本的应用,所以我们的项目也提供了小游戏版本。上周末已经有很多爸爸在微信小游戏里收到了他们的孩子使用 ABCmouse 制作的贺卡。值得一提的是,小游戏版本是我们两个开发在花了一周左右的时间内移植完成的。这里头主要的移植工作在于接入微信小游戏的登录授权,接入 VideoPlayer 和 InnerAudioContext 以分别支持视频播放和音频播放。 微信小游戏上的父亲节贺卡 第二个甜头是开发效率的提升。 首先,Cocos 提供了可视化的 Cocos Creator ,使用它来管理和构建工程非常轻松。 Cocos Creator 其次,设计萌妹子也能直接使用 Cocos Creator 编辑动效,输出动效资源给开发,提高协作效率。 设计妹子也使用 Cocos Creator 制作动画 另外,Cocos Creator 支持直接在浏览器中预览调试场景,节省了大把构建编译的耗时。 直接使用浏览器调试场景 第三个甜头是热更新带来的便利。 Cocos 同时支持脚本和资源的热更新,这给我们修复线上问题、发布运营活动带来了很大便利。 此外,Cocos 的热更新可以做到 hot reload,无需冷重启,很好的保证了用户的体验。并且,Cocos 的热更新支持高度可定制,可以很方便的定制满足业务需要的热更新流程。 ABCmouse 里的热更新 第四个甜头是 Cocos 提供的强大的社区支持。Cocos 的开发团队来自中国,有着非常活跃的中文社区。 Cocos 的中文论坛 另外,使用 Cocos 开发小游戏也成了最主要的方式,可见 Cocos 的受欢迎程度,也侧面证明了这套开发框架的生命力。 使用 Cocos 开发小游戏的占比 踩坑篇跨平台开发虽然方便,但是在一些具体的实践中难免也会踩到坑。 首先,Cocos 主要是面向游戏开发的,要使用它来开发应用,少不了需要开发一些 UI 组件。因此,我们在 Cocos 层开发了一系列的通用 UI 组件,包括对话框、选择器、表单、按钮、toast、loading 等组件,这些组件遵循一套规范化的接口标准,使用起来非常便捷灵活。 开发完 UI 组件后,我们发现这些组件的加载也存在问题。和原生应用开发不同,这些UI组件本质上都是挂载在场景里头的节点,如果没有调度的话,可能存在同时弹出多种弹窗和对话框的情况,整个场景就会变得很混乱。 没有调度的情况下,可能出现场景混乱 为了解决这种问题,我们写了一个针对 Cocos 的弹窗调度器,统一由它来调度弹窗,避免了弹窗的混乱。 有调度的情况 我们接下来遇到的另一个坑是 VideoPlayer 的置顶问题。 前面提到,Cocos 的场景是在 GL 上绘制的。例如,对于 Android 平台,Cocos 开启了一个 OpenGL 的 SurfaceView 来进行场景绘制。而这个 GLSurfaceView 不能直接支持渲染视频,所以,Cocos 提供了一个 VideoPlayer 组件用于播放视频。这个 VideoPlayer 是独立且置顶的一层。 这带来的一个问题是:无法在视频上绘制 UI 。 比如我们希望视频播放器里头能加上我们自定义的按钮、进度条,如果是直接在 Cocos 层对 VideoPlayer 进行封装的话,会发现这些 UI 元素会被视频本身遮盖,达不到定制界面的目的。 最终我们放弃了直接使用 Cocos 提供的 VideoPlayer 组件,而是在底层为各个端开发视频播放器,并各自实现界面的定制。 视频播放问题解决了,我们又遇到了音频播放的问题。 由于应用中有非常多的音乐、音效、语音,为了减小包大小,大部分的语音素材放在 CDN 上,需要的时候才从 CDN 上拉取播放。少部分常见的音效会直接打进应用包中。而 Cocos 自带的 AudioEngine 组件在 Native 端只支持本地资源的播放。因此,我们又封装了一个跨平台的音频播放器,可以自动根据指定的音频路径决定使用播放方式: 对于 Web 端或者 Native 端的本地资源文件,直接使用 AudioEngine 来播放。 对于 Native 端的远程音频,使用 Native 的播放器来播放。 对于小游戏环境,则使用小游戏的 InnerAudioContext 来播放。 由于对外的接口只有一套,开发者无需考虑具体的平台和底层播放器的选择。并且可以使用同样的接口来统一管理不同的音频。 跨平台的 AudioPlayer 最后我们遇到的一个比较严重的问题是 local reference table overflow error 问题。 为了复用 Native 端的能力,我们在 Cocos 层大量地使用反射机制来调用 Native 端提供的方法。然而,我们经常会遇到 local reference table overflow error 错误导致的界面卡死问题。 1234567891011121314151617A/art: art/runtime/indirect_reference_table.cc:138] JNI ERROR (app bug): local reference table overflow (max=512) A/art: art/runtime/indirect_reference_table.cc:138] local reference table dump: A/art: art/runtime/indirect_reference_table.cc:138] Last 10 entries (of 512): A/art: art/runtime/indirect_reference_table.cc:138] 511: 0x12e45170 java.lang.String "" A/art: art/runtime/indirect_reference_table.cc:138] 510: 0x12dd33c0 java.lang.Class<com.tencent.abcmouse.report.DcReport> A/art: art/runtime/indirect_reference_table.cc:138] 509: 0x12e45180 java.lang.String "" A/art: art/runtime/indirect_reference_table.cc:138] 508: 0x12f89490 java.lang.String "59" A/art: art/runtime/indirect_reference_table.cc:138] 507: 0x135a4f40 java.lang.String "1522668817662" A/art: art/runtime/indirect_reference_table.cc:138] 506: 0x12e89400 java.lang.String "onLoad" A/art: art/runtime/indirect_reference_table.cc:138] 505: 0x12e451d0 java.lang.String "" A/art: art/runtime/indirect_reference_table.cc:138] 504: 0x12c8bc00 java.lang.Class< A/art: art/runtime/indirect_reference_table.cc:138] 503: 0x12e451f0 java.lang.String "" A/art: art/runtime/indirect_reference_table.cc:138] 502: 0x134627f0 java.lang.String "1522668817664" A/art: art/runtime/indirect_reference_table.cc:138] Summary: A/art: art/runtime/indirect_reference_table.cc:138] 1 of android.opengl.GLSurfaceView$GLThread A/art: art/runtime/indirect_reference_table.cc:138] 222 of java.lang.Class (7 unique instances) A/art: art/runtime/indirect_reference_table.cc:138] 289 of java.lang.String (289 unique instances) 最初,我们怀疑是反射调用使用得太频繁导致。因此,我们对诸如打 log、事件上报等 Native 方法进行了频率限制,例如使用缓冲的方法将多个 log 合并后再打印。 然而,虽然这个做法减少了界面卡死的发生,但依然没有彻底杜绝问题的再次出现,就像是一个定时炸弹一样,威胁着我们应用的稳定性。 通过阅读引擎的代码,我们发现 Cocos 的引擎在反射阶段处理字符串参数时,使用了 NewStringUTF() 方法将其转换为 JNI 层的字符串,然而在调用执行完成后并没有相应地使用 DeleteLocalRef() 释放该字符串的引用,从而导致了引用表的溢出。 1234567891011121314151617181920212223static bool JavaScriptJavaBridge_callStaticMethod(se::State& s){ …… if (argc > 3) { …… if (call.isValid() && call.getArgumentsCount() == (argc - 3)) { …… for (int i = 0; i < count; ++i) { int index = i + 3; switch (call.argumentTypeAtIndex(i)) { …… case JavaScriptJavaBridge::ValueType::STRING: default: std::string str; seval_to_std_string(args[index], &str); jargs[i].l = call.getEnv()->NewStringUTF(str.c_str()); // 这里没有释放!!! break; } } ok = call.executeWithArgs(jargs); if (jargs) delete[] jargs; …… 了解到这个原因后,我们给 Cocos 的引擎提交了一个 pull request,修复了这个问题。 pull request 优化篇虽然 Cocos 比起纯 Hybrid 的方案在性能上已经占据了优势,但是比起 native 还是有一些差距的。下面就说说我们在开发过程中尝试过的一些优化,让我们的应用做到接近原生的体验。 高性能的 ScrollView官方 ScrollView 组件需要配合 layout 组件,当一次加载大量的子节点组件,或者分帧加载单个子节点组件时,初始化 ScrollView 节点视图会比较慢,在加载完成前存在拖动掉帧的问题。另外,一次性加载所有节点,也会导致内存资源的浪费。 下图这个场景是 ABCmouse 里的二级资源页,由于一次性加载了太多子节点,当屏幕滚动时,帧率降到了 8 fps 左右,给人的感受是非常卡顿。 官方 ScrollView 处理大量子节点导致滑动卡顿 我们对 ScrollView 进行了重写,基本的优化思路是:一次仅加载页面可容纳的少量数目子节点。并在滚动过程中,回收不可视的子节点组件并重用。 具体来说,ScrollView 大多数情况下表现为列表组件和宫格组件,以列表组件为例,可以根据子节点数目和子节点大小,计算出整个 ScrollView 内容的宽高,同时计算出屏幕可视区域最多可以容纳的子节点行数 rows,加载时仅加载 rows + 2 个子节点组件,其中添加的 2 个字节点组件作为滚动回收缓冲。 下图是对上述思路的图例。当手势向上,内容往下滚动时,一旦最上排的子节点组件不可视,就立马将它们回收掉并将其重用于将要渲染的子节点组件中。 高性能 ScrollView:滚动后 这么做的优点在于:一次仅加载页面可容纳的少量数目子节点,并且逐帧加载,能极大提升展示和滚动性能,另外大大减少了内存占用。 经过优化后,不管二级资源页场景里有多少元素需要展示,整体的帧率都维持在 60 fps 左右,非常流畅。 内存优化内存占用过高也是 Cocos 开发过程中很容易遇到的问题。如果没有优化好内存占用,很可能就会引发黑屏或者 OOM。 要优化内存占用,有几个思路。第一个思路是把内存消耗大以及没有回收的元凶先找出来对症下药。 于是,我们仿照 Cocos 的监视器也写了一个内存监视器,利用它来找出疑似存在内存泄漏的场景。 对于每一个场景,我们也对每个节点的内存占用做了一个排名,找出靠前的,分析是否合理,并进行针对性的优化。比如把原图缩小,把无需透明像素的png图转换成JPG图,等等。 第二个思路是为图片渲染开启纹理压缩,从而大幅度降低图片渲染的内存占用。Cocos 提供了 ETC1、PVR 等几种纹理压缩方案,其中,PVR 兼容性最好,内存消耗也最低,但是质量较差;ETC1 不支持 iOS 的低端机型,质量也较差。我们又对 Cocos2d-x 进行扩展,增加了 ETC2 纹理压缩,这种方案的优势比起 ETC1 而言,压缩质量更好。 下图可以看到 ETC2 和 PVR 压缩质量和内存占用的直观对比。对比原图,我们可以看出 ETC2 的压缩结果与原图相差不大,但内存减少了 75% 。而 PVR 的压缩结果相比 ETC2 言在细节方面少了很多,内存则减少了 87.5% 。 针对兼容性问题,我们设计了一种混合纹理压缩方案:对于高质量要求的纹理,如果该机型能支持ETC2,就使用ETC2纹理压缩;如果不支持,就将该纹理进行大小减半压缩;对于低质量要求的纹理,使用兼容性好的PVR纹理压缩。单图渲染的内存消耗可以降低接近 75%~87.5%。 纹理压缩是一项耗时的任务,所以我们把这项任务放在项目构建完后进行,而不是在客户端运行的时候才动态压缩。 我们编写了一个扩展工具,在构建完成后自动进行纹理压缩任务。后面我们发现这个工具压缩完一遍纹理要花费大概3分钟的时间,我们又改进成了增量压缩的方式,一次压缩任务缩短到10秒左右。 drawcall 优化每一帧的渲染耗时直接影响到整个应用的性能,而和渲染耗时相关的操作是 drawcall 。 什么是 drawcall 呢?我们可以看这张图来了解一下。在一帧的渲染过程中,场景会先被解析成场景树。场景树的每一个节点依次加入渲染队列中等待交付 GPU 渲染。GPU 接收渲染指令并执行的操作就叫做一次 drawcall。在一帧里头,drawcall 越少,性能当然就越好。 Cocos 针对 drawcall 优化已经提供了一种自动合并技术:比如,上图中的渲染指令 1、2 来自贴图 A,3、4 来自贴图 B ,5、6、7 来自贴图 C,这些指令会被分别合并优化,最终只产生 3 次 drawcall。我们要做的就是利用好这个自动合并技术。 首先可以找出浪费 drawcall 的节点对症下药。一般可以通过把节点的 active 属性设为 false 看看 drawcall 有没有大量减少来判断。 接下来我们可以利用好 Cocos 的合并技术。 对于静态的 Sprite ,可以使用合并图集来减少 drawcall 。例如使用 Cocos Creator 自带的 AutoAtlas 或者第三方工具 TexturePacker 。 文本的动态绘制也是 drawcall 浪费的重灾区。对于 Label,可以使用 BMFont 位图字体来取代普通文本,减少 drawcall 。 使用 BMFont 位图字体优化 Label 的 drawcall 目前这套优化方案还不能满足动态资源和动画的优化,我们也期待 Cocos 能够把 batching 技术做得更完善。 另外,还有另外一个需要注意的地方:小心避免跨层切换合图。Cocos 是按照节点层级顺序依次提交渲染指令的,如果不注重层级顺序,可能会导致贴图的切换从而浪费不必要的 drawcall 。 例如,下图中的渲染指令 4 使用的是贴图 C,直接卡在了渲染指令 3 和 5 之间,导致贴图 B 的渲染指令没法合并,从而浪费了多余的 drawcall。通过调整节点层级可以避免这个问题。 Hybrid 页面优化我们的应用里头目前依然存在一些原来的版本遗留下来的 H5 页面构成的场景,对于这些 H5 页面,我们也使用了一些比较常规的 Hybrid 优化技术,来达到首屏直出的要求。 因为已经有很多现有的优化方案了,所以这一块我并不打算细讲。简单为大家罗列几个技术点吧: 一个是使用离线缓存,对一些常用的 H5 场景,也可以离线打包进应用里头,优化首次启动速度。 一个是并行加载在 WebView 启动的同时并行地去拉资源,这样可以避免等待 WebView 初始化耗时对页面加载的影响。另外,还可以对一些 H5 页面进行预加载,减少等待。 一个是可以对页面进行少量标注,只增量更新需要动态变的部分。 通过这一系列的优化,我们的应用里头的 H5 页面的加载耗时也能够控制在 1 秒以内。 整体效果对比最后我们来看一下整体的改造效果。 项目整体的 Cocos 化率目前占到了 56%,剩下的还有 40% 的 H5 的页面(主要是一些小游戏),还有像视频这种 native 场景。 对比原来的场景启动耗时,经过一系列改造和优化后的场景都能控制在 1 秒内启动。 直接看数据不够直观,我们可以看一下原来加载耗时最长的一个场景,经过改造后做到了秒开。 涂色场景-改造后 而腾讯版本的包大小也比原来的版本小了 64% 。 欢迎扫码体验新版本的 ABCmouse :","tags":[{"name":"Cocos","slug":"Cocos","permalink":"http://oedx.github.io/tags/Cocos/"},{"name":"跨平台","slug":"跨平台","permalink":"http://oedx.github.io/tags/跨平台/"}]}]