【CEP专题】聊聊插件怎么更新

【CEP专题】聊聊插件怎么更新

之前在群里有很多小伙伴在讨论插件更新这个话题,我自己在插件开发这么些年,前后也折腾过各种插件的更新方式,今天在这里做一些整理,给大家一些参考。

1. 为什么要更新

当我们的插件已经发布到了用户手中,用户已经在正常使用我们的产品,但是我们又想做一些更新,比如提供了一个新的功能版本,或者需要修复一个严重的bug,这个时候就需要考虑插件更新的问题了,我们希望用户能够及时的更新到软件的最新版本,以保证最好的使用体验。

但是插件是一个类客户端App产品,不像网站那种可以随时更新,用户如果想要更新,得经历下载, 安装重启之类的操作,对于用户而言是一种负担。同时,以我的个人经验来看,用户对软件的更新会有自发的恐惧,他们会担心两点:

  1. 新版本不稳定,带来更多的问题
  2. 新版本会修改原有的交互,自己好不容易稳定下来的使用体验会被打乱

基于以上的两点,用户一般不太愿意去更新新版本,只要当前的版本能够满足需求,够用就行。这就会导致你的版本可能更新到很高了,依然还有许多用户在用一些很旧的版本。给大家看看我的切图工具的版本分布就能一探究竟。插件最新已经到4.3的版本了,依然还有大量的用户在用4.1甚至4.0的版本。

app version list

2. 如何更新

那如何才能让用户能够及时的更新到最新版本呢?我们可以分主动更新被动更新两种方式来考虑。主动更新指的是用户来自己选择是否更新,被动更新是插件自己完成更新,用户无感知。

无论哪种模式,我们都有一个必备的环节是:版本检测。我们需要在app里头设置一个版本号,并且在插件启动的时候,请求我们的服务器做新版本检查,如果存在新版本,再继续下一步的操作。

2.1 版本检测

这个过程其实很简单,在插件中内置版本号的配置

1
2
3
{
"version": "1.0.0"
}

在插件启动的时候,将版本号发送给服务器,服务器根据最新的版本号进行比对,返回是否有新版本以及更新的介绍。

new version

2.2 主动更新

主动更新,指的是用户自己进行更新,更新的过程由用户来进行主导和选择。当然,用户也可以选择不更新(如上图)。如果用户选择更新,会下载出新的版本安装包,用户安装后,重启ps,新版本生效。

你会发现这个主动更新的过程,就是将更新的决策权交给用户,然后用户一般都会选择不更新。但是我目前依然采用的是这种模式,我在文章后面会讲原因。

2.3 被动更新

我们来重点聊一下被动更新,就是在用户无感的情况下,让插件自动升级到最新的版本,这也应该是大家最想知道的内容。

方案1: 基于远程网页

前文提到了,我们访问的网站一般都可以做到自动更新,那我们也可以利用此机制到我们的插件当中。如果你还记得我前面的文章里头有介绍过,CEP的插件支持iframe,那我们就可以考虑将本地的插件面板变成一个壳,而真正的页面内容呢,都放到远程服务器上。这样每次我们只要将新版本插件更新到服务器上,用户就自动打开最新的版本了。

cep iframe page

这种方案,大家可能会好奇了,基于远程服务器的网页,也能和宿主进行交互么,调用jsx文件也能执行么?这里有两个需要注意的地方

  1. JSX文件加载

由于你的文件都在远程服务器,对于html/js/css这些就和传统的网页没有什么区别了,重点是JSX文件,由于我们一般都是通过$.evalFile这样的方式来加载jsx文件的,那远程的jsx文件如何来加载呢?

我们有两种方法来解决jsx文件加载的问题:

  • 将jsx文件当做字符串进行网络请求读取下来之后,通过evalScript来执行
  • 将jsx文件下载保存到本地目录下,再进行evalFile加载
  1. 与宿主的交互

以前旧版本的Photoshop对于iframe里头的页面不是做任何限制的,插件框架也依然会在你的iframe页面中的window对象中注入__adobe_cep__还有cep_node等这些对象,你完全可以正常使用CSInterface对象,和普通本地插件的使用没有任何区别。

但是等到CEP11的版本之后(photoshop CC2022),iframe就加入了同源限制,使得你在iframe里头调用CSInterfaceevalScript函数会拿不到回调

1
2
3
4
5
var cs = new CSInterface();
cs.evalScript('alert("hello world")', function(res) {
// 这个回调不会被调用
console.log(res)
})

这个时候,我们就得通过和父级窗口来进行通信,让本地插件的如何html文件来承载和宿主的交互,并将结果返回给iframe页面。父级和子级页面之间的通信,我们可以通过postMessage来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<html>
<head>
<title>cep</title>
<style>
* {margin: 0; padding: 0}
iframe {
margin: 0;
padding: 0;
border: none;
}
</style>
<script src="./CSInterface-6.1.0.js"></script>
<<script>
const cs = new CSInterface();
window.addEventListener("message", (evt) => {
const data = evt.data;
cs.evalScript(data.action, (result) => {
const page = document.getElementById("page");
console.log(result);
page.contentWindow.postMessage({action: data.action, result: result}, "*");
});
});

</script>
</head>
<body>
<iframe id="page" src="https://xxx.com/panel.html" width="245" height="300" />
</body>
</html>

在iframe的子页面,通过postMessage来发送消息给父级页面,父级页面通过addEventListener来监听消息,然后调用evalScript来执行宿主的jsx文件,最后将结果通过postMessage发送给子页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// iframe 页面
export default {
name: 'App',
data: () => {
return {
ret: ""
}
},
components: {
},
mounted() {
window.addEventListener("message", (evt) => {
const data = evt.data;
console.log(data);
this.ret = data.result;
});
},
methods: {
test() {
window.parent.postMessage({action: `alert("hello world")`}, "*")
}
}
}

整个流程是可以正常跑通的,但是你也会发现,它大幅增加了插件的复杂度,由于增加了iframe层的通信,使得你的插件在开发、调试、问题定位上都变得更加麻烦,并且还会影响你的编码结构,你需要将postMessage的通信进行二次封装,才能最终模拟出来默认evalScript的语法效果。

我们来评估一下这个方案的优缺点:

优点:

  1. 安装包会很小,因为插件只是一个壳,核心都在远程服务器上
  2. 可以实现自动更新,插件每次打开都是服务器最新的代码,完全没有版本的概念

缺点:

  1. 对用户的网络有强要求,无网或弱网的情况下无法正常使用你的产品
  2. 由于iframe的同源限制,使得你的插件在开发、调试、问题定位上都变得更加麻烦
  3. 外壳的的部分如果要升级,依然得用户下载安装更新,对外壳的功能设计有很强的通用性要求
  4. 还需要关注浏览器的缓存带来的文件更新不完整的问题

综合来看,这个方案虽然看起来很性感,但是在实际应用层面要踩的坑也非常多,总体来说,性价比不太高。

方案2: 本地覆盖

既然要做到无感更新,那我能不能直接插件后台默默的将新版本的文件下载下来,并覆盖现有的插件文件呢?看起来是一个简单直接的方法呀?

答案其实也是可以的。我们将新版本的文件下载下来直接覆盖安装到插件的目录文件夹下,保证文件名和目录结构一致,直接覆盖就可以了。下面给个简单的代码样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 挨个下载新版本的文件,下载完直接覆盖到插件的同级目录下
function onUpdate() {
// 获取插件的根目录
const dir = getExtensionDir();
const fileList = ["index.html", "panel.js", "jsx/init.jsx"];
const url = "https://static.cutterman.cn/static/other/panel/";
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
this.downloadFile(`${url}${file}?t=${new Date().getTime()}`, `${dir}/${file}`).then((file) => {
console.log(`download file[${file}] success`)
}).catch((e) => {
console.log(`download file[${file}] error[${e.toString()}]`)
});
}
}

download updated files

覆盖完文件之后,我们重启插件,就会发现它真的就是新版本了!重启插件也可以用代码来实现,于是整个流程就是后台默默下载更新的文件,并覆盖当前的插件文件,接着用代码执行插件重启(如何用代码重启插件,大家可以看我前面那篇隐藏面板的妙用的文章)。

panel updated

这个方案,看起来好像很完美?也不完全是,它也有一些问题:

  1. 它要求你的插件必须安装在用户目录的路径下,如果你的插件是安装在系统目录下,那么你就没有权限覆盖它了。但是安装在用户目录下,有时候可能会有问题,因为用户的电脑在用户管理上是很乱的,可能存在找不到你插件的情况。
  2. 下载的过程需要非常小心,因为你需要下载许多的文件,在网络传输的过程中会面临个各种文件损坏,下载不完整404等等情况,要保证下载的文件是正确的,要做好文件hash验证,网络失败异常处理等等。
  3. 某些情况下,文件覆盖会失败,比如你的index.html文件当前正在被引用,就可能导致无法进行覆盖。

综合来看,这个方案除了对插件的安装路径有要求之外,其他的都还勉强可以解决,总体比第一个方案要好很多。

方案3: 动态加载

这个方案,相当于是方案1和2的结合体,我们将主体面板也当做一个壳,但不通过iframe来加载远程页面了,而是将主体面板功能单独拆分出来一个模块,在打开面板的时候,先按照方案二的步骤将主体面板的文件全部下载下来,放到本地的某个位置(不一定需要在插件目录下)。接着在通过模块的入口文件加载进来主体内容。

panel file download

这种模式,比较适合那种插件内容很多,需要分模块的产品,比如大家都比较熟悉的设计助理这款产品,就是采用这种模式来实现的。在插件目录下的代码中,只有很少的内容,将不同的模块甚至版本,都通过动态下载文件的方式,存放在另外的位置,在加载进来,一定程度上还可以提高插件的安全性。

设计助理插件

这种方案的示例我就不贴了,大家可以自行去尝试。

这种方案的好处是,不要求你将插件安装在用户的目录下,只要将动态的内容部分下载下来,下载的内容放到有权限写的用户目录的某些位置就可以了(一般会放在%userdata%目录下)。这个方案带来的问题也和之前类似:

  1. 整体增加的插件的复杂度,对你的开发、调试、问题定位都提出了更高的要求,更加适合插件体量比较大的产品
  2. 稳定性层面也会受到影响,像设计助理这种通过动态加载html进来,就还得使用iframe,会一定程度降低插件的交互体验

总结

这篇文章我介绍了几种能够静默更新插件的方法,能够让用户无感知的去完成插件的更新,但是每一种方案都没法十全十美,各自存在优缺点,你可以根据自己当前的情况来进行选择。

综合来看,方案二是一个相对好一些的方案。虽然这些方案我在以前都陆续的尝试过,最终,还是选择了主动更新,这样的考虑主要从用户视角出发,自动更新从产品形态上其实是会造成用户的预期不稳定的,比如我昨天用的还是这个样子,第二天打开变成另外一个样子了,对使用者而言会带来恐慌。另外一个点是这些自动更新的方案都存在一定程度的失败率,不管你的更新过程写的多完整,用户的场景是多变的,比如你下载的文件被用户的杀毒软件给删掉了,就会导致更新不完整,造成产品不可用等等情况……于是我最终选择将更新的权利交给用户。虽然对于产品升级迭代不理想,但是用户的使用预期是稳定的,这本身也是用户体验的很重要一部分。

这篇文章就到这里了,如果你有什么问题,或者有更好的方案,可以在评论区留言,也可以加我们的微信群一起交流讨论。

评论