likes
comments
collection
share

记一个几乎零成本的小程序开发过程以及坑

作者站长头像
站长
· 阅读数 11

需求起因

我目前在做一个小红书账号,我的笔记主要分为两类,一类是技术类知识点,通常我会用Figma来完成作图,另外一类是心得观点,我一般会用手机自带的备忘录(我用的是锤子便签)虽然截出来的图很好看,但是发布到小红书之后字体看上去会很小,然后我又尝试了notion,效果还可以,但是这两个软件存在一个共同的问题,那就是无法按照小红书的尺寸比例进行分页,因为小红书是以图片流为主的,所以直接以文字形式发的话不利于阅读,最好是将文字变成图片,假如你生成的截图特别长,就不能直接看清楚,需要手动放大一下,这个很不利于传播,所以我迫切需要一个可以根据小红书比例进行自动分页截图的软件。找了一圈没有看到类似的,于是打算自己动手,将其命名为「字篇」。

在这里我想和大家分享一下关于独立开发产品的思考以及微信小程序开发过程中遇到的有意思的技术点和坑。

开始之前

之所以选择微信小程序,是因为其跨平台和背靠微信这个流量入口,加上自己也不会开发原生APP。我将其定位成工具,工具类应用在无法稳定盈利的前提下,我需要保证其基本上是零成本运行的。

该小程序的功能前期我主要定了两个,一个是写作,一个是导出图片。写作要具备随开随写,写完即走,导出要支持自定义字体、字体大小、主题样式和分页。

技术栈我选了京东的 taro 框架,基于 vite + vue3 进行开发。

几乎零成本

很多同学一上来就准备买域名、备案、买服务器,钱花了一大把,而使用人数却是寥寥无几,基本上不会给你带来收入,如果你依旧需要每个月交钱来维持服务的话,那就注定离注销不远了。所以我们迫切需要一个方案来降低成本,那么如何实现呢?

核心就三个字:静态化。什么意思呢?假如你做了一个诗词小程序,常规做法就是买台服务器,写获取诗词的接口,还得买数据库,但实际上你细想一下就会发现,获取数据的方式并不是只能通过接口,如果我们把这些原本需要存放在数据库中的数据变成静态文件,那么我们就可以将其存放到阿里云或者七牛云这样的静态资源存储服务上面,这样做的好处有以下几点

  • 不需要再购买服务器或者写接口,省了很大的成本
  • 有了 CDN 的加持,速度比接口获取更快
  • 前期访问量不是很高的话基本上免费额度足以
  • 不需要买域名,用其自带的二级域名即可
  • 不需要购买SSL证书
  • 不需要域名备案

目前「字篇」的所有服务基本上都是用的阿里云OSS,几乎零成本运行,没有购买域名和备案。

写作

写作区我使用了 editor 组件,因为可能牵涉到一些富文本,打开小程序即进入写作界面,点击空白即可开始写,点击加号新增一篇,争取做到简洁方便易用。

记一个几乎零成本的小程序开发过程以及坑

数据

数据默认存放在手机本地,所以这是一个离线可使用的本地笔记小程序,每当用户新建或编辑的时候,都会及时保存笔记文件到小程序本地。

但是会存在问题,假如删除小程序那么数据会随之被删除,因为小程序的文件系统是应用隔离的,无法做到永久保存到手机上。所以接下来我会加上一个同步到服务端的功能,需要手动开启。前期我也考虑到了这一点,所以笔记ID都是走的 nanoid 后期同步到服务端也不会产生冲突。

截图

截图下载我使用了 offscreenCanvastoDataURL 方法,当然这里涉及到文字的分段,由于可以定制截图配置,所以我需要一个前置方法,将内容按照当前的主题和排版分成N行,计算每行包含哪些文字,由于canvas无法自动进行换行,所以这里主要用到了 measureText 方法来测量文字宽度,你可以想到,更换字体大小的话就需要重新执行一次分段,但是更换主题颜色是不需要的,这样就能合理节省时间了。

记一个几乎零成本的小程序开发过程以及坑

当然这里面还存在一些细节,比如在英文单词的两边加上空格,我写了一个简单的正则替换


addEnWordsSpace(content) {
     return content.replace(/[A-Za-z\s\d\.]+/g, val => ` ${val.trim().replace(/\s+/g, ' ')} `);
 }

分页

分页算是比较核心的一个功能,但是实现并不难,首先我将分页的模式分成3种

  • 按固定页数
  • 按每页高度固定分页
  • 小红书智能分页

然后不管是上面哪种模式,最终都归根到每一页有多高,然后遍历前面的分段数组从前到后得出每页需要渲染哪些段文字。

自定义字体

接下来说说本次遇到的最大的坑,因为是写作小程序,所以支持自定义字体截图就成了很有必要的一个功能了,目前小程序支持通过loadFontFace加载自定义字体,但是会存在一个问题,中文字体比较大,如果每次都是网络加载的话会耗费大量的流量,而直接将其放到小程序包里面又不行,因为包的大小是有限制的,所以只剩下唯一的方案,那就是只加载一次,后面统一走本地缓存,但是默认小程序是不支持本地字体文件的,所以该怎么实现呢?

其实很简单,loadFontFace除了支持网络地址外,还支持 base64 格式,这样一来我们就可以将字体文件转换成base64格式(工具 transfonter)然后将其存放到服务端,接下来只需要在加载字体的时候将其下载到本地并缓存成文件,后续读取缓存即可

export async function loadFont(name) {
    const base64 = await fetchBase64(name); // 读取字体base64
    return new Promise(resolve => {
        wx.loadFontFace({
            family: name,
            global: true,
            source: `url("${url}")`,
            scopes: ['webview', 'native'],
            complete() {
                resolve();
            }
        })
    })
}


// 获取base64内容
async function fetchBase64(name) {
    const cache = await readFontCache(name);
    if (cache) {
        return cache;
    }

    return new Promise(resolve => {
        // 读取文件缓存
        const url = `https://xxx.com/font-${name}.json`;
        wx.request({
            url,
            header: {
                'content-type': 'application/json' // 默认值
            },
            success(res) {
                // 写入文件缓存
                writeCache(name, res.data[0])
                resolve(res.data[0]);
            }
        })
    })
}


// 读取字体缓存
function readFontCache(name) {
    const fs = wx.getFileSystemManager();
    return new Promise(resolve => {
        fs.readFile({
            filePath: `${wx.env.USER_DATA_PATH}/font-${name}-base64.txt`,
            encoding: 'utf8',
            position: 0,
            success(res) {
                resolve(res.data)
            },
            fail() {
                resolve(null)
            }
        })
    })
}

// 写入缓存
function writeCache(name, data) {
    const fs = wx.getFileSystemManager();
    return new Promise(resolve => {
        fs.writeFile({
            filePath: `${wx.env.USER_DATA_PATH}/font-${name}-base64.txt`,
            data,
            encoding: 'utf8',
            complete() {
                resolve(true)
            }
        })

    })
}

base64 还有一个好处就是,因为是纯文本,我们可以将其进行任意切割,比如我将其中一部分放到本地,另外一部分放到服务端,这样也可以节省一部分流量。

一切如我料,开发工具上我可以成功使用自定义字体了,并且只会加载一次,我很得意,正当我准备庆祝的时候,我发现真机预览的时候无法渲染了(我的是安卓手机,不知道苹果有没有这个问题)嗯,然后到社区搜了一下,果然有很多人遇到同样的问题了,而且至今官方没有解决,不愧是腾讯的开发团队,常规操作了属于。

但是我迫切需要这个功能,既然官方不给我答案,我就只能自己动手了,然后我开始思考canvas是如何使用自定义字体进行文本绘制的,其实本质上就跟SVG差不多,都是一些路径的集合,那么我们能不能手动将字体文件转换成canvas的路径呢?

然后我搜索了一下,果真有这样一个库叫 opentype.js 它可以引入字体文件并传入要渲染的文字内容,最终将其转换成 canvas 的 path 并绘制

import opentype from 'opentype.js';

// 加载字体
const font = this.config.font.family;
if (font !== 'system') {
    const buffer = await loadFontBuffer(font);
    this.fontype = await opentype.parse(buffer);
}

// 获取字体文件流
async function loadFontBuffer(name) {
    const base64 = await fetchBase64(name);
    const buffer = wx.base64ToArrayBuffer(base64);
    return buffer;
}


// 绘制
if (this.config.font.family === 'system') {
    this.ctx.fillText(text, 0, 0);
    return
}
const path = this.fontype.getPath(text, 0, fontSize, fontSize);
path.fill = this.config.theme.text;
path.draw(this.ctx);

真机测试了一下,可以完美渲染出来!在这里我想说的一点就是,前端同学要保持自己的知识面,只有见多识广你才能知道这个问题也许是可以用xx方式解决的,否则你可能压根不会朝这个方向去想。

图片绘制

截图主题中有一个背景图的样式,实现也不难,读取图片绘制即可,但是需要注意的是画布尺寸会因为内容的大小导致高度不定,那么就需要背景图片是动态剪裁并适配填充绘制的,这样才不会出现拉伸变形的问题,这里我直接在 so 上面找了一个方法,亲测可用

function drawImageFill(ctx, img, x, y, w, h, offsetX, offsetY) {

    if (arguments.length === 2) {
        x = y = 0;
        w = ctx.canvas.width;
        h = ctx.canvas.height;
    }

    // default offset is center
    offsetX = typeof offsetX === "number" ? offsetX : 0.5;
    offsetY = typeof offsetY === "number" ? offsetY : 0.5;

    // keep bounds [0.0, 1.0]
    if (offsetX < 0) offsetX = 0;
    if (offsetY < 0) offsetY = 0;
    if (offsetX > 1) offsetX = 1;
    if (offsetY > 1) offsetY = 1;

    var iw = img.width,
        ih = img.height,
        r = Math.min(w / iw, h / ih),
        nw = iw * r,   // new prop. width
        nh = ih * r,   // new prop. height
        cx, cy, cw, ch, ar = 1;

    // decide which gap to fill    
    if (nw < w) ar = w / nw;                             
    if (Math.abs(ar - 1) < 1e-14 && nh < h) ar = h / nh;  // updated
    nw *= ar;
    nh *= ar;

    // calc source rectangle
    cw = iw / (nw / w);
    ch = ih / (nh / h);

    cx = (iw - cw) * offsetX;
    cy = (ih - ch) * offsetY;

    // make sure source rectangle is valid
    if (cx < 0) cx = 0;
    if (cy < 0) cy = 0;
    if (cw > iw) cw = iw;
    if (ch > ih) ch = ih;

    // fill image in dest. rectangle
    ctx.drawImage(img, cx, cy, cw, ch,  x, y, w, h);
}

接下来我又遇到了前面的问题,开发工具可以渲染,但是真机渲染不出来,不愧是微信小程序!

怎么办,还得解决不是。接下来我发现并非所有情况下都有问题,而是在截图大于1页的时候,或者再次进入的时候,结合我的查找最终发现了问题所在,canvas.createImage方法创建的图片对象如果遇到重复的图片加载,他会自动形成缓存,不会触发onload以及任何事件,就卡在那了,sheet!

于是乎我只能使用单例模式将其做成内存缓存

function getImage(canvas, src) {
    const image = canvas.createImage();
    const fileName = src || `1.webp`;
    if (Factory.images[fileName]) return Factory.images[fileName];
    image.src = `https://xxx.com/bg/${fileName}?time=${Date.now()}`;
    return new Promise(resolve => {
        image.onload = function() {
            Factory.images[fileName] = image;
            resolve(image);
        }

        image.onerror = function(e) {
            resolve(null);
        }
        setTimeout(() => {
            resolve(null);
        }, 10000); // 10 秒超时
    })
}

这里有个小的细节,我使用的图片是 webp 格式,经测试微信小程序的 canvas 是支持的,这样又能节省一些流量了。顺便分享一下我使用资源图的方法,首先找一个免费可商用的图片网站(我一般在这里找)然后在这里将下载的图片转成 WebP 格式,最后再去TinyPNG上面压缩一下,这样得到的图片大小就足够小了。

目前「字篇」还处于初级阶段,接下来还有大量的开发和优化工作,但是我仍然遵循简洁的原则,后续遇到的一些技术点(比如无接口登录和数据同步)以及坑我也会在这篇文章里面更新,如果你有什么问题也欢迎提出来一起讨论。