- BeautyWe.js 一套专注于微信小程序的开发范式
摘要: 小程序框架… 作者:JerryC 原文:BeautyWe.js 一套专注于微信小程序的开发范式 Fundebug经授权转载,版权归原作者所有。 [图片] 官网:beautywejs.com Repo: beautywe 一个简单的介绍 BeautyWe.js 是什么? 它是一套专注于微信小程序的企业级开发范式,它的愿景是: 让企业级的微信小程序项目中的代码,更加简单、漂亮。 为什么要这样命名呢? Write beautiful code for wechat mini program by the beautiful we! 「We」 既是我们的 We,也是微信的 We,Both beautiful! 那么它有什么卖点呢? 专注于微信小程序环境,写原汁原味的微信小程序代码。 由于只专注于微信小程序,它的源码也很简单。 插件化的编程方式,让复杂逻辑更容易封装。 再加上一些配套设施: 一些官方插件。 一套开箱即用,包含了工程化、项目规范以及微信小程序环境独特问题解决方案的框架。 一个CLI工具,帮你快速创建应用,页面,组件等。 它由以下几部分组成: 一个插件化的核心 - BeautyWe Core 对 App、Page 进行抽象和包装,保持传统微信小程序开发姿势,同时开放部分原生能力,让其具有「可插件化」的能力。 一些官方插件 — BeautyWe Plugins 得益于 Core 的「可插件化」特性,封装复杂逻辑,实现可插拔。官方对于常见的需求提供了一些插件:如增强存储、发布/订阅、状态机、Logger、缓存策略等。 一套开箱即用的项目框架 - BeautyWe Framework 描述了一种项目的组织形式,开箱即用,集成了 [代码]BeautyWe Core[代码] ,并且提供了如:全局窗口、开发规范、多环境开发、全局配置、NPM 等解决方案。 一个CLI工具 - BeautyWe Cli 提供快速创建应用、页面、插件,以及项目构建功能的命令行工具。并且还支持自定义的创建模板。 一个简单的例子 下载 [图片] 用 BeautyWe 包装你的应用 [图片] 之后,你就能使用 BeautyWe Plugin 提供的能力了。 [图片] 开放原生App/Page,支持插件化 [代码]new BtApp({...})[代码] 的执行结果是对原生的应用进行包装,其中包含了「插件化」的处理,然后返回一个新的实例,这个实例适配原生的 [代码]App()[代码] 方法。 下面来讲讲「插件化」到底做了什么事情。 首先,插件化开放了原生 App 的四种能力: Data 域 把插件的 Data 域合并到原生 App 的 Data 域中,这一块很容易理解。 原生钩子函数 使原生钩子函数(如 [代码]onShow[代码], [代码]onLoad[代码])可插件化。让原生App与多个插件可以同时监听同一个钩子函数。如何工作的,下面会细说。 事件钩子函数 使事件钩子函数(与 view 层交互的钩子函数),尽管在实现上有一些差异,但是实现原理跟「原生钩子函数」一样的。 自定义方法 让插件能够给使用者提供 API。为了保证插件提供的 API 足够的优雅,支持当调用插件 API 的时候(如 event 插件 [代码]this.event.on(...)[代码]),API 方法内部仍然能通过 [代码]this[代码] 获取到原生实例。 钩子函数的插件化 原生钩子函数,事件钩子函数我们统一称为「钩子函数」。 对于每一个钩子函数,内部是维护一个以 Series Promise 方式执行的执行队列。 以 [代码]onShow[代码] 为例,将会以这样的形式执行: native.onShow → pluginA.onShow → pluginB.onShow → … 下面深入一下插件化的原理: [图片] 工作原理是这样的: 经过 [代码]new BtApp(...)[代码] 包装,所有的钩子函数,都会有一个独立的执行队列, 首先会把原生的各个钩子函数 [代码]push[代码] 到对应的队列中。然后每 [代码]use[代码] 插件的时候,都会分解插件的钩子函数,往对应的队列 [代码]push[代码]。 当 [代码]Native App[代码](原生)触发某个钩子的时候,[代码]BtApp[代码] 会以 Promise Series 的形式按循序执行对应队列里面的函数。 特殊的,[代码]onLaunch[代码] 和 [代码]onLoad[代码] 的执行队列中,会在队列顶部插入一个初始化的任务([代码]initialize[代码]),它会以同步的方式按循序执行 [代码]Initialize Queue[代码] 里面的函数。这正是插件生命周期函数中的 [代码]plugin.initialize[代码]。 这种设计能提供以下功能: 可插件化。 只需要往对应钩子函数的事件队列中插入任务。 支持异步。 由于是以 Promise Series 方式运行的,其中一个任务返回一个 Promise,下一个任务会等待这个任务完成再开始。如果发生错误,会流转到原生的 [代码]onError()[代码] 中。 解决了微信小程序 [代码]app.js[代码] 中 [代码]getApp() === undefinded[代码]问题。 造成这个问题,本质是因为 [代码]App()[代码] 的时候,原生实例未创建。但是由于 Promise 在 event loop 中是一个微任务,被注册在下一次循环。所以 Promise 执行的时候 [代码]App()[代码] 早已经完成了。 一些官方插件 BeautyWe 官方提供了一系列的插件: 增强存储: Storage 数据列表:List Page 缓存策略:Cache 日志:Logger 事件发布/订阅:Event 状态机:Status 它们的使用很简单,哪里需要插哪里。 由于篇幅的原因,下面挑几个比较有趣的来讲讲,更多的可以看看官方文档:BeautyWe 增强存储 Storage 该功能由 @beautywe/plugin-storage 提供。 由于微信小程序原生的数据存储生命周期跟小程序本身一致,即除用户主动删除或超过一定时间被自动清理,否则数据都一直可用。 所以该插件在 [代码]wx.getStorage/setStorage[代码] 的基础上,提供了两种扩展能力: 过期控制 版本隔离 一些简单的例子 安装 [代码]import { BtApp } from '@beautywe/core'; import storage from '@beautywe/plugin-storage'; const app = new BtApp(); app.use(storage()); [代码] 过期控制 [代码]// 7天后过期 app.storage.set('name', 'jc', { expire: 7 }); [代码] 版本隔离 [代码]app.use({ appVersion: '0.0.1' }); app.set('name', 'jc'); // 返回 jc app.get('name'); // 当版本更新后 app.use({ appVersion: '0.0.2' }); // 返回 undefined; app.get('name'); [代码] 更多的查看 @beautywe/plugin-storage 官方文档 数据列表 List Page 对于十分常见的数据列表分页的业务场景,[代码]@beautywe/plugin-listpage[代码] 提供了一套打包方案: 满足常用「数据列表分页」的业务场景 支持分页 支持多个数据列表 自动捕捉下拉重载:[代码]onPullDownRefresh[代码] 自动捕捉上拉加载:[代码]onReachBottom[代码] 自带请求锁,防止帕金森氏手抖用户 简单优雅的 API 一个简单的例子: [代码]import BeautyWe from '@beautywe/core'; import listpage from '@beautywe/plugin-listpage'; const page = new BeautyWe.BtPage(); // 使用 listpage 插件 page.use(listpage({ lists: [{ name: 'goods', // 数据名 pageSize: 20, // 每页多少条数据,默认 10 // 每一页的数据源,没次加载页面时,会调用函数,然后取返回的数据。 fetchPageData({ pageNo, pageSize }) { // 获取数据 return API.getGoodsList({ pageNo, pageSize }) // 有时候,需要对服务器的数据进行处理,dataCooker 是你定义的函数。 .then((rawData) => dataCooker(rawData)); }, }], enabledPullDownRefresh: true, // 开启下拉重载, 默认 false enabledReachBottom: true, // 开启上拉加载, 默认 false })); // goods 数据会被加载到,goods 为上面定义的 name // this.data.listPage.goods = { // data: [...], // 视图层,通过该字段来获取具体的数据 // hasMore: true, // 视图层,通过该字段来识别是否有下一页 // currentPage: 1, // 视图层,通过该字段来识别当前第几页 // totalPage: undefined, // } [代码] 只需要告诉 [代码]listpage[代码] 如何获取数据,它会自动处理「下拉重载」、「上拉翻页」的操作,然后把数据更新到 [代码]this.data.listPage.goods[代码] 下。 View 层只需要描述数据怎么展示: [代码]<view class="good" wx:for="listPage.goods.data"> ... </view> <view class="no-more" wx:if="listPage.goods.hasMore === false"> 没有更多了 </view> [代码] [代码]listpage[代码] 还支持多数据列表等其他更多配置,详情看:@beautywe/plugin-listpage 缓存策略 Cache [代码]@beautywe/plugin-cache[代码] 提供了一个微信小程序端缓存策略,其底层由 super-cache 提供支持。 特性 提供一套「服务端接口耗时慢,但加载性能要求高」场景的解决方案 满足最基本的缓存需求,读取(get)和保存(set) 支持针对缓存进行逻辑代理 灵活可配置的数据存储方式 How it work 一般的请求数据的形式是,页面加载的时候,从服务端获取数据,然后等待数据返回之后,进行页面渲染: [图片] 但这种模式,会受到服务端接口耗时,网络环境等因素影响到加载性能。 对于加载性能要求高的页面(如首页),一般的 Web 开发我们有很多解决方案(如服务端渲染,服务端缓存,SSR 等)。 但是也有一些环境不能使用这种技术(如微信小程序)。 Super Cache 提供了一个中间数据缓存的解决方案: [图片] 思路: 当你需要获取一个数据的时候,如果有缓存,先把旧的数据给你。 然后再从服务端获取新的数据,刷新缓存。 如果一开始没有缓存,则请求服务端数据,再把数据返回。 下一次请求缓存,从第一步开始。 这种解决方案,舍弃了一点数据的实时性(非第一次请求,只能获取上一次最新数据),大大提高了前端的加载性能。 适合的场景: 数据实时性要求不高。 服务端接口耗时长。 使用 [代码]import { BtApp } from '@beautywe/core'; import cache from '@beautywe/plugin-cache'; const app = new BtApp(); app.use(cache({ adapters: [{ key: 'name', data() { return API.fetch('xxx/name'); } }] })); [代码] 假设 [代码]API.fetch('xxx/name')[代码] 是请求服务器接口,返回数据:[代码]data_from_server[代码] 那么: [代码]app.cache.get('name').then((value) => { // value: 'data_from_server' }); [代码] 更多的配置,详情看:@beautywe/plugin-cache 日志 Logger 由 [代码]@beautywe/logger-plugin[代码] 提供的一个轻量的日志处理方案,它支持: 可控的 log level 自定义前缀 日志统一处理 使用 [代码]import { BtApp } from '@beautywe/core'; import logger from '@beautywe/plugin-logger'; const page = new BtApp(); page.use(logger({ // options })); [代码] API [代码]page.logger.info('this is info'); page.logger.warn('this is warn'); page.logger.error('this is error'); page.logger.debug('this is debug'); // 输出 // [info] this is info // [warn] this is warn // [error] this is error // [debug] this is debug [代码] Level control 可通过配置来控制哪些 level 该打印: [代码]page.use(logger({ level: 'warn', })); [代码] 那么 [代码]warn[代码] 以上的 log ([代码]info[代码], [代码]debug[代码])就不会被打印,这种满足于开发和生成环境对 log 的不同需求。 level 等级如下: [代码]Logger.LEVEL = { error: 1, warn: 2, info: 3, debug: 4, }; [代码] 更多的配置,详情看:@beautywe/plugin-logger BeautyWe Framework [代码]@beautywe/core[代码] 和 [代码]@beautywe/plugin-...[代码] 给小程序提供了: 开放原生,支持插件化 —— by core 各种插件 —— by plugins 但是,还有很多的开发中实际还会遇到的痛点,是上面两个解决不到的。 如项目的组织、规范、工程化、配置、多环境等等 这些就是,「BeautyWe Framework」要解决的范畴。 它作为一套开箱即用的项目框架,提供了这些功能: 集成 BeautyWe Core NPM 支持 全局窗口 全局 Page,Component 全局配置文件 多环境开发 Example Pages 正常项目需要的标配:ES2015+,sass,uglify,watch 等 以及我们认为良好的项目规范(eslint,commit log,目录结构等) 也是由于篇幅原因,挑几个有趣的来讲讲,更多的可以看看官方文档:BeautyWe 快速创建 首先安装 [代码]@beautywe/cli[代码] [代码]$ npm i @beautywe/cli -g [代码] 创建应用 [代码]$ beautywe new app > appName: my-app > version: 0.0.1 > appid: 123456 > 这样可以么: > { > "appName": "my-app", > "version": "0.0.1", > "appid": "123456" > } [代码] 回答几个问题之后,项目就生成了: [代码]my-app ├── gulpfile.js ├── package.json └── src ├── app.js ├── app.json ├── app.scss ├── assets ├── components ├── config ├── examples ├── libs ├── npm ├── pages └── project.config.json [代码] 创建页面、组件、插件 页面 主包页面:[代码]beautywe new page <path|name>[代码] 分包页面:[代码]beautywe new page --subpkg <subPackageName> <path|name>[代码] 组件 [代码]beautywe new component <name>[代码] 插件 [代码]beautywe new plugin <name>[代码] 自定义模板 在 [代码]./.templates[代码] 目录中,存放着快速创建命令的创建模板: [代码]$ tree .templates .templates ├── component │ ├── index.js │ ├── index.json │ ├── index.scss │ └── index.wxml ├── page │ ├── index.js │ ├── index.json │ ├── index.scss │ └── index.wxml └── plugin └── index.js [代码] 可以修改里面的模板,来满足项目级别的自定义模板创建。 全局窗口 我们都知道微信小程序是「单窗口」的交互平台,一个页面对应一个窗口。 而在业务开发中,往往会有诸如这种述求: 自定义的 toast 样式 页面底部 copyright 全局的 loading 样式 全局的悬浮控件 … 稍微不优雅的实现可以是分别做成独立的组件,然后每一个页面都引入进来。 这种做法,我们会有很多的重复代码,并且每次新建页面,都要引入一遍,后期维护也会很繁琐。 而「全局窗口」的概念是:希望所有页面之上有一块地方,全局性的逻辑和交互,可以往里面搁。 global-view 组件 这是一个自定义组件,源码在 [代码]/src/components/global-view[代码] 每个页面的 wxml 只需要在顶层包一层: [代码]<global-view id="global-view"> ... </global-view> [代码] 需要全局实现的交互、样式、组件,只需要维护这个组件就足够了。 全局配置文件 在 [代码]src/config/[代码] 目录中,可以存放各种全局的配置文件,并且支持以 Node.js 的方式运行。(得益于 Node.js Power 特性)。 如 [代码]src/config/logger.js[代码]: [代码]const env = process.env.RUN_ENV || 'dev'; const logger = Object.assign({ prefix: 'BeautyWe', level: 'debug', }, { // 开发环境的配置 dev: { level: 'debug', }, // 测试环境的配置 test: { level: 'info', }, // 线上环境的配置 prod: { level: 'warn', }, }[env] || {}); module.exports.logger = logger; [代码] 然后我们可以这样读取到 config 内容: [代码]import { logger } from '/config/index'; // logger.level 会根据环境不同而不同。 [代码] Beautywe Framework 默认会把 config 集成到 [代码]getApp()[代码] 的示例中: [代码]getApp().config; [代码] 多环境开发 BeautyWe Framework 支持多环境开发,其中预设了三套策略: dev test prod 我们可以通过命令来运行这三个构建策略: [代码]beautywe run dev beautywe run test beautywe run prod [代码] 三套环境的差异 Beautywe Framework 源码默认在两方面使用了多环境: 构建任务([代码]gulpfile.js/env/...[代码]) 全局配置([代码]src/config/...[代码]) 构建任务的差异 构建任务 说明 dev test prod clean 清除dist文件 √ √ √ copy 复制资源文件 √ √ √ scripts 编译JS文件 √ √ √ sass 编译scss文件 √ √ √ npm 编译npm文件 √ √ √ nodejs-power 编译Node.js文件 √ √ √ watch 监听文件修改 √ scripts-min 压缩JS文件 √ sass-min 压缩scss文件 √ npm-min 压缩npm文件 √ image-min 压缩图片文件 √ clean-example 清除示例页面 √ Node.js Power Beautywe Framework 的代码有两种运行环境: Node.js 运行环境,如构建任务等。 微信小程序运行环境,如打包到 [代码]dist[代码] 文件夹的代码。 运行过程 Node.js Power 本质是一种静态编译的实现。 把某个文件在 Node.js 环境运行的结果,输出到微信小程序运行环境中,以此来满足特定的需求。 Node.js Power 会把项目中 [代码]src[代码] 目录下类似 [代码]xxx.nodepower.js[代码] 命名的文件,以 Node.js 来运行, 然后把运行的结果,以「字面量对象」的形式写到 [代码]dist[代码] 目录下对应的同名文件 [代码]xxx.nodepower.js[代码] 文件去。 以 [代码]src/config/index.nodepower.js[代码] 为例: [代码]const fs = require('fs'); const path = require('path'); const files = fs.readdirSync(path.join(__dirname)); const result = {}; files .filter(name => name !== 'index.js') .forEach((name) => { Object.assign(result, require(path.join(__dirname, `./${name}`))); }); module.exports = result; [代码] 该文件,经过 Node.js Power 构建之后: [代码]dist/config/index.nodepower.js[代码]: [代码]module.exports = { "appInfo": { "version": "0.0.1", "env": "test", "appid": "wx85fc0d03fb0b224d", "name": "beautywe-framework-test-app" }, "logger": { "prefix": "BeautyWe", "level": "info" } }; [代码] 这就满足了,随意往 [代码]src/config/[代码] 目录中扩展配置文件,都能被自动打包。 Node.js Power 已经被集成到多环境开发的 dev, test, prod 中去。 当然,你可以手动运行这个构建任务: [代码]$ gulp nodejs-power [代码] NPM BeautyWe Framework 实现支持 npm 的原理很简单,总结一句话: 使用 webpack 打包 [代码]src/npm/index.js[代码] ,以 commonjs 格式输出到 [代码]dist/npm/index.js[代码] [图片] 这样做的好处: 实现简单。 让 npm 包能集中管理,每次引入依赖,都好好的想一下,避免泛滥(尤其在多人开发中)。 使用 [代码]ll dist/npm/index.js[代码] 命令能快速看到项目中的 npm 包使占了多少容量。 新增 npm 依赖 在 [代码]src/npm/index.js[代码] 文件中,进行 export: [代码]export { default as beautywe } from '@beautywe/core'; [代码] 然后在其他文件 import: [代码]import { beautywe } from './npm/index'; [代码] 更多 总的来说,BeautyWe 是一套微信小程序的开发范式。 [代码]core[代码] 和 [代码]plugins[代码] 扩展原生,提供复杂逻辑的封装和插拔式使用。 而 [代码]framework[代码] 则负责提供一整套针对于微信小程序的企业级项目解决方案,开箱即用。 其中还有更多的内容,欢迎浏览官网:beautywejs.com 关于Fundebug Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了10亿+错误事件,付费客户有阳光保险、核桃编程、荔枝FM、掌门1对1、微脉、青团社等众多品牌企业。欢迎大家免费试用!
2019-07-09 - 小程序如何生成海报分享朋友圈
摘要: 小程序开发必备技能啊… 原文:小程序如何生成海报分享朋友圈 作者:小白 Fundebug经授权转载,版权归原作者所有。 项目需求写完有一段时间了,但是还是想回过来总结一下,一是对项目的回顾优化等,二是对坑的地方做个记录,避免以后遇到类似的问题。 需求 利用微信强大的社交能力通过小程序达到裂变的目的,拉取新用户。 生成的海报如下: [图片] 需求分析 1、利用小程序官方提供的api可以直接分享转发到微信群打开小程序 2、利用小程序生成海报保存图片到相册分享到朋友圈,用户长按识别二维码关注公众号或者打开小程序来达到裂变的目的 实现方案 一、分析如何实现 相信大家应该都会有类似的迷惑,就是如何按照产品设计的那样绘制成海报,其实当时我也是不知道如何下手,认真想了下得通过canvas绘制成图片,这样用户保存这个图片到相册,就可以分享到朋友圈了。但是要绘制的图片上面不仅有文字还有数字、图片、二维码等且都是活的,这个要怎么动态生成呢。认真想了下,需要一点一点的将文字和数字,背景图绘制到画布上去,这样通过api最终合成一个图片导出到手机相册中。 二、需要解决的问题 二维码的动态获取和绘制(包括如何生成小程序二维码、公众号二维码、打开网页二维码) 背景图如何绘制,获取图片信息 将绘制完成的图片保存到本地相册 处理用户是否取消授权保存到相册 三、实现步骤 这里我具体写下围绕上面所提出的问题,描述大概实现的过程 ①首先创建canvas画布,我把画布定位设成负的,是为了不让它显示在页面上,是因为我尝试把canvas通过判断条件动态的显示和隐藏,在绘制的时候会出现问题,所以采用了这种方法,这里还有一定要设置画布的大小。 [代码]<canvas canvas-id="myCanvas" style="width: 690px;height:1085px;position: fixed;top: -10000px;"></canvas> [代码] ②创建好画布之后,先绘制背景图,因为背景图我是放在本地,所以获取 <canvas> 组件 canvas-id 属性,通过createCanvasContext创建canvas的绘图上下文 CanvasContext 对象。使用drawImage绘制图像到画布,第一个参数是图片的本地地址,后面两个参数是图像相对画布左上角位置的x轴和y轴,最后两个参数是设置图像的宽高。 [代码]const ctx = wx.createCanvasContext('myCanvas') ctx.drawImage('/img/study/shareimg.png', 0, 0, 690, 1085) [代码] ③创建好背景图后,在背景图上绘制头像,文字和数字。通过getImageInfo获取头像的信息,这里需要注意下在获取的网络图片要先配置download域名才能生效,具体在小程序后台设置里配置。 获取头像地址,首先量取头像在画布中的大小,和x轴Y轴的坐标,这里的result[0]是我用promise封装返回的一个图片地址 [代码]let headImg = new Promise(function (resolve) { wx.getImageInfo({ src: `${app.globalData.baseUrl2}${that.data.currentChildren.headImg}`, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) let avatarurl_width = 60, //绘制的头像宽度 avatarurl_heigth = 60, //绘制的头像高度 avatarurl_x = 28, //绘制的头像在画布上的位置 avatarurl_y = 36; //绘制的头像在画布上的位置 ctx.save(); // 先保存状态 已便于画完圆再用 ctx.beginPath(); //开始绘制 //先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针 ctx.arc(avatarurl_width / 2 + avatarurl_x, avatarurl_heigth / 2 + avatarurl_y, avatarurl_width / 2, 0, Math.PI * 2, false); ctx.clip(); //画了圆 再剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 ctx.drawImage(result[0], avatarurl_x, avatarurl_y, avatarurl_width, avatarurl_heigth); // 推进去图片 [代码] 这里举个例子说下如何绘制文字,比如我要绘制如下这个“字”,需要动态获取前面字数的总宽度,这样才能设置“字”的x轴坐标,这里我本来是想通过measureText来测量字体的宽度,但是在iOS端第一次获取的宽度值不对,关于这个问题,我还在微信开发者社区提了bug,所以我想用另一个方法来实现,就是先获取正常情况下一个字的宽度值,然后乘以总字数就获得了总宽度,亲试是可以的。 [图片] [代码]let allReading = 97 / 6 / app.globalData.ratio * wordNumber.toString().length + 325; ctx.font = 'normal normal 30px sans-serif'; ctx.setFillStyle('#ffffff') ctx.fillText('字', allReading, 150); [代码] ④绘制公众号二维码,和获取头像是一样的,也是先通过接口返回图片网络地址,然后再通过getImageInfo获取公众号二维码图片信息 ⑤如何绘制小程序码,具体官网文档也给出生成无限小程序码接口,通过生成的小程序可以打开任意一个小程序页面,并且二维码永久有效,具体调用哪个小程序二维码接口有不同的应用场景,具体可以看下官方文档怎么说的,也就是说前端通过传递参数调取后端接口返回的小程序码,然后绘制在画布上(和上面写的绘制头像和公众号二维码一样的) [代码]ctx.drawImage('小程序码的本地地址', x轴, Y轴, 宽, 高) [代码] ⑥最终绘制完把canvas画布转成图片并返回图片地址 [代码] wx.canvasToTempFilePath({ canvasId: 'myCanvas', success: function (res) { canvasToTempFilePath = res.tempFilePath // 返回的图片地址保存到一个全局变量里 that.setData({ showShareImg: true }) wx.showToast({ title: '绘制成功', }) }, fail: function () { wx.showToast({ title: '绘制失败', }) }, complete: function () { wx.hideLoading() wx.hideToast() } }) [代码] ⑦保存到系统相册;先判断用户是否开启用户授权相册,处理不同情况下的结果。比如用户如果按照正常逻辑授权是没问题的,但是有的用户如果点击了取消授权该如何处理,如果不处理会出现一定的问题。所以当用户点击取消授权之后,来个弹框提示,当它再次点击的时候,主动跳到设置引导用户去开启授权,从而达到保存到相册分享朋友圈的目的。 [代码]// 获取用户是否开启用户授权相册 if (!openStatus) { wx.openSetting({ success: (result) => { if (result) { if (result.authSetting["scope.writePhotosAlbum"] === true) { openStatus = true; wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } } }, fail: () => { }, complete: () => { } }); } else { wx.getSetting({ success(res) { // 如果没有则获取授权 if (!res.authSetting['scope.writePhotosAlbum']) { wx.authorize({ scope: 'scope.writePhotosAlbum', success() { openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) }, fail() { // 如果用户拒绝过或没有授权,则再次打开授权窗口 openStatus = false console.log('请设置允许访问相册') wx.showToast({ title: '请设置允许访问相册', icon: 'none' }) } }) } else { // 有则直接保存 openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } }, fail(err) { console.log(err) } }) } [代码] 总结 至此所有的步骤都已实现,在绘制的时候会遇到一些异步请求后台返回的数据,所以我用promise和async和await进行了封装,确保导出的图片信息是完整的。在绘制的过程确实遇到一些坑的地方。比如初开始导出的图片比例大小不对,还有用measureText测量文字宽度不对,多次绘制(可能受网络原因)有时导出的图片上的文字颜色会有误差等。如果你也遇到一些比较坑的地方可以一起探讨下做个记录,下面附下完整的代码 [代码]import regeneratorRuntime from '../../utils/runtime.js' // 引入模块 const app = getApp(), api = require('../../service/http.js'); var ctx = null, // 创建canvas对象 canvasToTempFilePath = null, // 保存最终生成的导出的图片地址 openStatus = true; // 声明一个全局变量判断是否授权保存到相册 // 获取微信公众号二维码 getCode: function () { return new Promise(function (resolve, reject) { api.fetch('/wechat/open/getQRCodeNormal', 'GET').then(res => { console.log(res, '获取微信公众号二维码') if (res.code == 200) { console.log(res.content, 'codeUrl') resolve(res.content) } }).catch(err => { console.log(err) }) }) }, // 生成海报 async createCanvasImage() { let that = this; // 点击生成海报数据埋点 that.setData({ generateId: '点击生成海报' }) if (!ctx) { let codeUrl = await that.getCode() wx.showLoading({ title: '绘制中...' }) let code = new Promise(function (resolve) { wx.getImageInfo({ src: codeUrl, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) let headImg = new Promise(function (resolve) { wx.getImageInfo({ src: `${app.globalData.baseUrl2}${that.data.currentChildren.headImg}`, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) Promise.all([headImg, code]).then(function (result) { const ctx = wx.createCanvasContext('myCanvas') console.log(ctx, app.globalData.ratio, 'ctx') let canvasWidthPx = 690 * app.globalData.ratio, canvasHeightPx = 1085 * app.globalData.ratio, avatarurl_width = 60, //绘制的头像宽度 avatarurl_heigth = 60, //绘制的头像高度 avatarurl_x = 28, //绘制的头像在画布上的位置 avatarurl_y = 36, //绘制的头像在画布上的位置 codeurl_width = 80, //绘制的二维码宽度 codeurl_heigth = 80, //绘制的二维码高度 codeurl_x = 588, //绘制的二维码在画布上的位置 codeurl_y = 984, //绘制的二维码在画布上的位置 wordNumber = that.data.wordNumber, // 获取总阅读字数 // nameWidth = ctx.measureText(that.data.wordNumber).width, // 获取总阅读字数的宽度 // allReading = ((nameWidth + 375) - 325) * 2 + 380; // allReading = nameWidth / app.globalData.ratio + 325; allReading = 97 / 6 / app.globalData.ratio * wordNumber.toString().length + 325; console.log(wordNumber, wordNumber.toString().length, allReading, '获取总阅读字数的宽度') ctx.drawImage('/img/study/shareimg.png', 0, 0, 690, 1085) ctx.save(); // 先保存状态 已便于画完圆再用 ctx.beginPath(); //开始绘制 //先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针 ctx.arc(avatarurl_width / 2 + avatarurl_x, avatarurl_heigth / 2 + avatarurl_y, avatarurl_width / 2, 0, Math.PI * 2, false); ctx.clip(); //画了圆 再剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 ctx.drawImage(result[0], avatarurl_x, avatarurl_y, avatarurl_width, avatarurl_heigth); // 推进去图片 ctx.restore(); //恢复之前保存的绘图上下文状态 可以继续绘制 ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.setFontSize(28); // 文字字号 ctx.fillText(that.data.currentChildren.name, 103, 78); // 绘制文字 ctx.font = 'normal bold 44px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(wordNumber, 325, 153); // 绘制文字 ctx.font = 'normal normal 30px sans-serif'; ctx.setFillStyle('#ffffff') ctx.fillText('字', allReading, 150); ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText('打败了全国', 26, 190); // 绘制文字 ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#faed15'); // 文字颜色 ctx.fillText(that.data.percent, 154, 190); // 绘制孩子百分比 ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText('的小朋友', 205, 190); // 绘制孩子百分比 ctx.font = 'normal bold 32px sans-serif'; ctx.setFillStyle('#333333'); // 文字颜色 ctx.fillText(that.data.singIn, 50, 290); // 签到天数 ctx.fillText(that.data.reading, 280, 290); // 阅读时长 ctx.fillText(that.data.reading, 508, 290); // 听书时长 // 书籍阅读结构 ctx.font = 'normal normal 28px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(that.data.bookInfo[0].count, 260, 510); ctx.fillText(that.data.bookInfo[1].count, 420, 532); ctx.fillText(that.data.bookInfo[2].count, 520, 594); ctx.fillText(that.data.bookInfo[3].count, 515, 710); ctx.fillText(that.data.bookInfo[4].count, 492, 828); ctx.fillText(that.data.bookInfo[5].count, 348, 858); ctx.fillText(that.data.bookInfo[6].count, 212, 828); ctx.fillText(that.data.bookInfo[7].count, 148, 726); ctx.fillText(that.data.bookInfo[8].count, 158, 600); ctx.font = 'normal normal 18px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(that.data.bookInfo[0].name, 232, 530); ctx.fillText(that.data.bookInfo[1].name, 394, 552); ctx.fillText(that.data.bookInfo[2].name, 496, 614); ctx.fillText(that.data.bookInfo[3].name, 490, 730); ctx.fillText(that.data.bookInfo[4].name, 466, 850); ctx.fillText(that.data.bookInfo[5].name, 323, 878); ctx.fillText(that.data.bookInfo[6].name, 184, 850); ctx.fillText(that.data.bookInfo[7].name, 117, 746); ctx.fillText(that.data.bookInfo[8].name, 130, 621); ctx.drawImage(result[1], codeurl_x, codeurl_y, codeurl_width, codeurl_heigth); // 绘制头像 ctx.draw(false, function () { // canvas画布转成图片并返回图片地址 wx.canvasToTempFilePath({ canvasId: 'myCanvas', success: function (res) { canvasToTempFilePath = res.tempFilePath that.setData({ showShareImg: true }) console.log(res.tempFilePath, 'canvasToTempFilePath') wx.showToast({ title: '绘制成功', }) }, fail: function () { wx.showToast({ title: '绘制失败', }) }, complete: function () { wx.hideLoading() wx.hideToast() } }) }) }) } }, // 保存到系统相册 saveShareImg: function () { let that = this; // 数据埋点点击保存学情海报 that.setData({ saveId: '保存学情海报' }) // 获取用户是否开启用户授权相册 if (!openStatus) { wx.openSetting({ success: (result) => { if (result) { if (result.authSetting["scope.writePhotosAlbum"] === true) { openStatus = true; wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } } }, fail: () => { }, complete: () => { } }); } else { wx.getSetting({ success(res) { // 如果没有则获取授权 if (!res.authSetting['scope.writePhotosAlbum']) { wx.authorize({ scope: 'scope.writePhotosAlbum', success() { openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) }, fail() { // 如果用户拒绝过或没有授权,则再次打开授权窗口 openStatus = false console.log('请设置允许访问相册') wx.showToast({ title: '请设置允许访问相册', icon: 'none' }) } }) } else { // 有则直接保存 openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } }, fail(err) { console.log(err) } }) } }, [代码]
2019-06-15