普通视图

发现新文章,点击刷新页面。
昨天以前小猪的博客

一段简单逆向之旅-绕开Xcode 13.3最低macOS 12.0限制

作者 DreamPiggy
2022年3月26日 04:47

因为众所周知的原因,苹果的Xcode版本会不断提高自己的最低安装版本,在Xcode 13.0-13.2.1上,这个最低安装版本是macOS 11

而随着Xcode 13.3正式版放出,这个最低部署版本在最后关头被提升到了macOS 12

Why?

一般来说,各位开发者或者众多基建,总有各种各样的原因需要暂时留在老版本的macOS系统上,但是又希望使用新Xcode版本自带的Toolchain进行一些工作开发调试,有些是主观问题,有些是客观限制:

举例子:

  1. macOS 12禁止了sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off绕过GUI配置,导致一些公司采取Apple Device Management管理的电脑,无法正常关闭TCP拦截,会导致一些服务异常
  2. macOS 12加强了Kernel Extension的安全性,导致GitHub Action和Circle CI截止2022年3月底,迟迟无法更新他们的虚拟化集群到macOS 12,只有11.6的最新版本

这些都是闲聊,进入正题。那么有没有办法能够绕开,或者从原理上来讲,是否这个系统绑定的最低部署版本限制是必要的?

先放结论

  1. 可以绕过这个macOS 12的最低安装版本限制运行
  2. 这个限制是非必要的,绕过以后所有功能正常可用(构建,独立工具集,调试,连接iPhone)

下面来说明具体的逆向流程,和进行绕过的简单Step-by-step手法

安装Xcode

先说明测试机器Mac环境和Xcode环境:

  1. macOS 11.4 (20F71)
  2. Xcode Version 13.2 (13C90):需要保留一份以防万一
  3. Xcode Version 13.3 (13E113):目标安装的版本

首先,作为iOS/macOS开发者,我们肯定会使用dmg的格式,或者使用Xcodes.app来安装我们的Xcode 13.3了(App Store安装Xcode曾经出的坑:App Store version of Xcode 13.2 causing problems for developers,我是不会再用了)

安装完毕后,我们在Finder中看到的Xcode.app是一个画着❎的样子,直接打开会提示如下:

1648210752406_af87e2021743b9639d35f86714f127c7

绕过GUI部分的限制

修改LSMinimumSystemVersion

作为iOS/macOS开发者,我们第一想到的就是,是否是Xcode.app对应的Info.plist中,设置了和最低部署版本相关的字段导致拒绝载入呢?

我们用另一个Xcode(或者plistutil)打开Xcode.app/Contents/Info.plist,果然发现了对应的字段:

1648210752770_b314e1b23bdd01fe803ede1383e29491

这个LSMinimumSystemVersion是Mac应用标准的声明最低部署版本的方式,修改为你的机器当前OS版本之后保存,执行

1
2
touch /Applications/Xcode-13.3.0.app
killall Finder

重新尝试双击。不错,这次我们打开了,初看起来不错(直到我们正式开始编译)!

1648210752322_3d4e9ae78eaab94de9cfc7d1f8eaecf0

绕过CLI部分的限制

神奇的xcrun

但是只要创建一下工程并执行编译,就会发现,各种命令行工具的调用是有问题的,比如我们先通过xcode-select设置为当前的Xcode 13.2,尝试执行:

1648210752844_f21e111a081ff5dfc61dab7137109a39
1648210752266_e83287242af496cbbec7817b4b60f9f0

但是我们如果直接找到,执行对应绝对路径的clang,是可以执行的

1648210752227_fd8cd9f89407475fe62c0489dc61a232

并且,我们可以直接检查clang这个二进制,是否链接时设置了target,这部分可以使用otool -l读取machO Header查看到:

1648210752133_02c1257232efb1e2adebcb51ea25ceb0

好,最低部署版本是macOS 10.14.6;那现在我们有充分的证据说明,一定可以在我当前的电脑运行clang,而上述提示应该是xcrun这个调度器,添加了额外的判断。

通过搜索关键词,可以在Xcode的strings输出中找到这句“Executable requires at least”的关键字:参考仓库:Xcode.app-strings

反编译libxcodebuildLoader

我们定位到这个libxcodebuildLoader.dylib,拖进Hopper尝试反编译理解他检查的原理,伪代码如下:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
void _checkMinimumOSVersion(int arg0) {
var_2C = 0x0;
rbx = 0x0;
rax = _NSGetExecutablePath(0x0, &var_2C);
rdi = var_2C;
if (rdi != 0x0) {
rbx = malloc(rdi);
}
rax = _NSGetExecutablePath(rbx, &var_2C);
if (rax != 0x0) goto loc_282c;

loc_26fc:
rax = [NSString stringWithUTF8String:rbx];
rax = [rax retain];
r14 = rax;
r15 = [[NSURL fileURLWithPath:rax] retain];
if (rbx != 0x0) {
free(rbx);
}
rbx = CFBundleCopyInfoDictionaryForURL(r15);
CFRelease(r15);
if (rbx == 0x0) goto loc_2867;

loc_276a:
r13 = [CFDictionaryGetValue(rbx, @ DVTMinimumSystemVersion ) retain];
CFRelease(rbx);
if ((r13 == 0x0) || ([r13 length] == 0x0)) goto loc_280c;

loc_27a6:
r12 = *_objc_msgSend;
r15 = [[DVTVersion versionWithStringValue:r13] retain];
rax = [DVTVersion currentSystemVersion];
rax = [rax retain];
rbx = r12;
r12 = rax;
rdx = r15;
if ([rax isEqualToOrNewerThanVersion:rdx] == 0x0) goto loc_288c;

loc_27fb:
[r12 release];
[r15 release];
goto loc_280c;

loc_280c:
[r13 release];
[r14 release];
return;

loc_288c:
r15 = [(rbx)(r15, @selector(stringValue), rdx) retain];
rax = (rbx)(r12, @selector(stringValue), rdx);
rax = [rax retain];
r14 = [(rbx)(@class(NSString), @selector(stringWithFormat:), @ Executable requires at least macOS %@, but is being run on macOS %@, and so is exiting. , r15, rax) retain];
[rax release];
[r15 release];
fprintf(**___stderrp, %s\n , (rbx)(objc_retainAutorelease(r14), @selector(UTF8String), @ Executable requires at least macOS %@, but is being run on macOS %@, and so is exiting. ));
goto loc_292b;

loc_292b:
exit(0x1);
return;

loc_2867:
fwrite( Unable to open executable info dictionary; xcodebuild may be corrupt and should be reinstalled.\n , 0x60, 0x1, **___stderrp);
goto loc_292b;

loc_282c:
_DVTAssertionFailureHandler(*_self, *__cmd, void checkMinimumOSVersion() , /Library/Caches/com.apple.xbs/Sources/IDETools/IDETools-20008/xcodebuildLoader/xcodebuildLoader.m , 0x69, @ 0 , @ Couldn't get executable path to self! );
return;
}

好,阅读伪代码以及查阅资料可知:

xcrun会先一步调用到xcodebuild,检查DVTMinimumSystemVersion这个变量的值是否和当前OS版本匹配。

而这个变量,竟然是通过CFBundleCopyInfoDictionaryForURL打开的。

参考苹果的函数说明,它除了常规的打开一个.bundle的文件夹,解析为NSBundle.infoDictionary以外,竟然能打开存在于二进制__TEXT,__info_plist中的数据来解析为一个字典。所以我们接下来去找xcodebuild的二进制看看。

1648210752247_62087827de34d7b56eea1e48208b261e

参考:

  1. _NSGetExecutablePath函数说明,大概理解获取当前程序的可执行路径

反编译xcodebuild

同时,出于好奇,我们可以再把xcodebuild拖进Hopper去尝试理解,发现它整个程序竟然只有一个main函数,逻辑其实都在libxcodebuildLoader.dylib

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void _main(int arg0, int arg1) {
r14 = arg1;
rbx = arg0;
rax = dlopen( @rpath/libxcodebuildLoader.dylib , 0x1);
if (rax == 0x0) goto loc_100002b7f;

loc_100002b5c:
rax = dlsym(rax, XcodeBuildMain );
if (rax == 0x0) goto loc_100002bd8;

loc_100002b70:
(rax)(rbx, r14);
return;

loc_100002bd8:
rax = dlerror();
rax = [NSString stringWithUTF8String:rax];
rax = [rax retain];
rax = objc_retainAutorelease(rax);
r15 = rax;
rax = [rax UTF8String];
rsi = Error loading symbol: %s\n ;
goto loc_100002c2f;

loc_100002c2f:
fprintf(**___stderrp, rsi);
rbx = [_prunedErrorMessage() retain];
[r15 release];
if (rbx != 0x0) {
_main.cold.1(rbx, @selector(UTF8String));
}
return;

loc_100002b7f:
rax = dlerror();
rax = [NSString stringWithUTF8String:rax];
rax = [rax retain];
rax = objc_retainAutorelease(rax);
r15 = rax;
rax = [rax UTF8String];
rsi = Error loading required libraries. If there is an ongoing installation please wait for it to complete. Otherwise reinstall. (%s)\n ;
goto loc_100002c2f;
}

修改DVTMinimumSystemVersion

其实大家也发现了,xcodebuild二进制本身竟然内嵌了一段XML!我使用llvm-objdump把它直接提取了出来:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildMachineOSBuild</key>
<string>21E160</string>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleDisplayName</key>
<string>xcodebuild</string>
<key>CFBundleIdentifier</key>
<string>com.apple.dt.xcodebuild</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>xcodebuild</string>
<key>CFBundleShortVersionString</key>
<string>13.3</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CFBundleVersion</key>
<string>20008</string>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key>
<string>21E185d</string>
<key>DTPlatformName</key>
<string>macosx</string>
<key>DTPlatformVersion</key>
<string>12.3</string>
<key>DTSDKBuild</key>
<string>21E185d</string>
<key>DTSDKName</key>
<string>macosx12.3.internal</string>
<key>DTXcode</key>
<string>1330</string>
<key>DTXcodeBuild</key>
<string>13E112a</string>
<key>DVTMinimumSystemVersion</key>
<string>12.0</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
</dict>
</plist>

看到我们关心的DVTMinimumSystemVersionLSMinimumSystemVersion都在里面。其实也侧面证明了,真正的最低部署版本是macOS 11.0,而不是macOS 12.0(12.0只是苹果为了间接Push Developer去频繁更新macOS的阴谋罢了😂)

那下一步,要做的事情就是用魔改xcodebuild并重新codesign。修改的方式多种多样,你暴力使用Hex Editor也是最简单。但是我更好奇的是这个__TEXT,__info_plist的machO段和节的相关说明。

在网上搜索了一下相关资料,很容易就找到了感兴趣的资料:

  1. The Power Of Plist:解释Info.plist可以内嵌在二进制中
  2. Gimmedebugah: how to embedded a Info.plist into arbitrary binaries:对任意已有二进制注入Info.plist
  3. llvm-objcopy:拷贝修改machO结构到新machO

基本解释得很明确清晰,如果你有源码,可以直接利用ld64的参数 --sectcreate __TEXT,__info_plist path_to/Info.plist来注入你的Info.plist信息。没有源码可以手动修改machO结构并签名即可。

对于我此次跑Xcode 13.3来说,我选择最傻瓜最直观的Hex Editor修改(我用的是开源小工具HexFiend),只需要把12.0修改为11.0即可满足我的需要,并重新codesign一波。

1648210752222_811d0d800592f601f309e48b732f1194

codesign:

1
2
codesign --remove-signature /Applications/Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild
sudo /Applications/Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild -license

测试一下CLI,很正常

1648210752374_0a5d6ef92b0e270d6164a05153565572

最终替换步骤

  1. 修改Xcode-13.3.0.app/Contents/Info.plist中的LSMinimumSystemVersion的值为11.0
  2. 替换Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild中的DVTMinimumSystemVersion的二进制为11.0,或者使用我这个已经替换好的(建议还是手动参考上面步骤[修改DVTMinimumSystemVersion]替换,授人以渔而不是授人以鱼)

近期参与的APNG和WebP开源项目的经历及感受

作者 DreamPiggy
2017年7月26日 06:44

这篇文章讲的是有关近期自己参与的几个开源项目的经历以及感受,不过巧合的是内容都和APNG和WebP这两种图像格式相关,阅读前建议先简单略读一下之前写的一篇文章:客户端上动态图格式对比和解决方案

SDWebImage

SDWebImage是iOS平台上非常著名的图片下载、缓存库,而今年发布的SDWebImage 4.0在架构、接口变动并带来性能优化的同时,还支持了Animated WebP,因此我就高兴地去实验了一下,本想着可以替代之前使用的YYImage。但是一测试就发现渲染不正常,追回去看源码,发现SDWebImage的实现可以说是Too naive,压根没有按照WebP规范实现,大部分Animated WebP动图渲染都挂了,完全不可用(连测试都过不了,更别说生产环境了)。演示Demo在此:AnimatedWebPDemo

总结出来的具体问题有以下几个:

  1. SD绘制每帧的canvas大小不正确,在代码中,直接取得当前帧frame的大小,而非整个canvas的大小。这就导致最后生成的所有帧图片的数组中,每帧的图像大小不一致。这样渲染就会出现Bug(把所有帧拉伸到最大的那个图像大小上)。
  2. SD的实现没有考虑过WebP Disposal Method,这个在很多动图中都会用到,因为能够重复利用前一帧的画布,来大幅减少最后生成动图的体积。常见的动图格式如GIF、APNG生成工具一般都采用这种Disposal,不然最终文件体积较大(但Google提供的WebP工具暂时没有自带这种优化的方式,一般使用第三方工具处理)。
  3. UIKit自带的UIImage.animatedImages是非常弱的,SD并没有提供额外的抽象,而是直接用的这个接口。这带来的最大的问题,是UIImage需要提供一个图片数组和总时长,但是会对数组中每个图片平均分配时长。这与Animated WebP的规范就是不同的,后者允许对每帧设置一个不同的持续时长。
  4. UIImageView直接设置image属性,是不支持设置循环次数的,会默认无限循环播放。而有些Animated WebP图片需要有循环次数。

既然知道这么多坑,想着SD毕竟是主流框架,就赶紧提了Issue,但是过了一周多,SD社区依然没有任何回应。于是尝试自己一个个解决。最后的成果也比较好,上述4个问题都得到了解决。

Canvas大小问题

这个问题,可以直接通过libwebp的API,修改来使用canvas大小而不是frame大小,确保每帧最后的图像大小相同。其中,为了优化性能,对于透明的且frame比canvas要小的帧,绘制出来等价于将frame平移,然后所有剩余部分填充透明值。在使用CGBitmapContext的时候,可以直接在要传入的Bitmap矢量数据上做变换,减少绘制带来的开销(不过CGBitmapContext本身应该有优化,对于这个开销影响不大,但参考YYImage里面有这一步处理)

Disposal Method支持

在绘制每帧时,按照Animated WebP规范,共享一个全局的CGContext当作canvas,根据每帧不同的Disposal Method,如果为Disposal Background,则在绘制完当前帧后清空CGContext,否则的话不处理,保留到下一帧继续绘制,最终测试和YYImage行为一致。

每帧持续时长相等问题

这个问题相对比较麻烦,因为你无法改动UIKit实现方式。最后想了一个比较Trick的方式。思路也简单,考虑这样的情况:第1帧持续时间:50ms,第2帧持续时间:100ms,第3帧持续时间:150ms,总共时长300ms。在依然使用UIImage的接口情况下(即数组每帧时长平均分配),那就可以提供一个[1, 2, 2, 3, 3, 3](元素表示帧的编号)的图像数组,总时长300ms。这样的话平均分到每个元素是50ms,表面上看是6帧但实际渲染是3帧,也能达到最后的显示效果。这样实现的话,只要求一个所有帧持续时间的gcd,然后对每帧图像,按该帧所占的比例重复添加多次就可以了。

循环次数问题

由于SD的接口问题(用到了UIImageView的sd_setImageWithURL),是直接设置到UIImageView.image上的,而不是animationImages。而直接设置image会无视掉animationRepeatCount这个本来用于设置循环次数的属性。但如果SD框架自动设置animationImages 属性的话,可能对使用者现有代码有影响(因为使用者还是用的image属性而不是animationImages属性),因此最后的解决方案,是在UIImage的扩展中,单独提供了一个sd_webpLoopCount的属性来获取循环次数,使用者可以自行设置UIImageView的属性,来实现指定循环次数。

举个例子,一般情形下(显示的动图超过循环次数后停到最后一帧上)就可以这样子用。

1
2
3
4
5
6
7
[imageView sd_setImageWithURL:webpURL completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
imageView.image = image.images.lastObject;
imageView.animationDuration = image.duration;
imageView.animationRepeatCount = image.sd_webpLoopCount;
imageView.animationImages = image.images;
[imageView startAnimating];
}];

这也算是一个解决方式吧。

感受

在写完这些,跑过单元测试,提交了Pull request之后,回头来看,才能真正感到YYImage的实力。

YYImage通过一个抽象层YYImageFrame,来把GIF、APNG和Animated WebP三种格式统一到一起,并且提供了Encoder和Decoder可以在三种格式来互相转换(这是重点)。关于绘制部分,还使用到了Accelerate Framework,通过vImage的GPU加速的Bitmap变换来替代部分CGBitmapContext绘制。在缓存上,由于SD的抽象层存在,他使用了ImageIO来直接缓存CGImageSource(SD采用的是缓存了WebP的rawData),效率提升了很高也减少缓存大小(速度对比的话,可以从那个Demo工程看到,checkout到fix_sd_animated_webp_canvas_size分支上运行)。想想还是挺佩服ibireme这个人的,看来以后还要多使用YYKit并多学习。

apng2webp

apng2webp是一个转换APNG到Animated WebP图片的命令行工具,使用Python脚本 + 外部命令行工具来实现。在之前的工作需求中,使用到来优化APNG的大小,并且产出Animated WebP来让客户端使用。

为什么要转换APNG到Animated WebP呢,其实是因为APNG这个规范由于没有进入到PNG标准规范中,一直处于一个不温不火的地步,网上的APNG动图数量也不多,很多网页的PNG图片上传也不支持。虽然如今各大浏览器都对APNG提供了支持(Chrome 59正式支持了APNG,iOS很早从8.0支持,FireFox就是亲爹一直推动),但是客户端上,Android端没有相对靠谱的解码和渲染组件能够使用。反倒是Animated WebP借助Google亲爹推动,成为Android天生支持的图像格式,并且iOS上也有YYImage来提供支持。随着WebP的流行,越来越多设备估计都会支持WebP和Animated WebP,甚至最终超越GIF这个广为流行,但是已有30年历史,只支持256色和1位alpha通道的古老动图格式。

这次对apng2webp项目,主要是贡献了两个功能。

  1. Windows的支持,即现在三大桌面端命令行均可使用
  2. CI自动Build和Test

Windows的支持

由于整个外部命令行工具(有四个工具,其中cwebpwebpmux是Google官方提供的,有Windows Build,另两个是源码编译)都是UNIX工具链下的,依赖几个C++库也挺常见,但是尝试过使用VS 2015源码编译跪了,使用vcpkg这个非常新的Windows上的C++包管理工具,又爆了一堆link error。对于我这种C++菜鸟来说,最后只好选择了直接上Mysys2和MinGW-w64,一键pacman -S安装依赖,cmake makefile可用,跑了一遍测试也没问题,确实非常方便。由于MinGW-w64的编译产物,会依赖于libgcc,winpthreads,为了使最后的分发方便,于是在Windows上改用静态链接。

CI和单元测试

关于Python的单元测试,由于这是一个简单的命令行工具,最后就通过引入pytest,直接对main函数和外部工具进行了测试,写起来也特别简单(自动匹配文件名和类名这点挺好)。用起来感觉比起Objective-C和Java的工具要好用多了。

在CI Build上,对于Linux和macOS的话,一般都会使用GitHub官方合作的Travis CI,配置使用yml语法,再加上一系列的Bash命令。而Windows上使用的Appveyor也非常好用,自带了VS 2012,2015,2017Msys2MinGW-w64cmake等一系列工具,上手开箱即用。配置的话注意要使用CMD或者PowerShell,如果不熟悉,甚至可以用Msys2装一些UNIX工具来搞定(好处之一)。

感受

总体来说,这个项目主要是苦力活,不过也算熟悉了一下UNIX工具在Windows上移植的一种手段,而且还学习到了pytest和开源项目的CI Build方式,也算有点意思吧。

iSparta

iSparta是一个图形化的APNG和WebP转换工具,包含了很多功能(APNG合成,WebP转换,图片压缩等),虽说是开源项目,但是上一次提交已经是三年前了。而我最希望的APNG转换Animated WebP功能却没有实现(这也难怪,三年前Animated WebP规范还没出来)。大概看了一眼,使用的是NW.js(其实用的是改名前叫做node-webkit的东西),是一个和Electron类似的,使用前端技术栈来构建跨平台应用的框架,本质上都是一个Chromium的运行环境来提供渲染,再加上node.js来提供JS Runtime。上手相对容易。

基本上的目标,是为了提供更好的GUI工具,因此主要就参考了一下iSparta的Issue,解决这几个问题:

  1. 支持APNG转换Animated WebP
  2. 支持i18n国际化

由于我并不是专业前端出身(大二学过一段时间前端基本知识和Node.js简单应用,也接触过React Native),经过近两天的奋斗,才终于磕磕碰碰完成。期间遇到过各种问题(NW.js的问题,node第三方库的问题,跨平台行为不一致的问题等等),不过在这里略过说一下重点吧。

APNG支持Animated WebP

关于这个功能,自然可以想到上面的apng2webp命令行工具,不过由于apng2webp本身是Python写的脚本来调用外部工具,没必要在NW.js里打包一个Python环境。因此最后就决定直接在JS里,实现了相同逻辑的脚本来完成。不过实话说这部分花费的时间不长,在GUI布局上才是重头。大体框架参考了项目中的已有写法,但CSS的部分由于实在生疏(原项目有一些布局Hack),最后使用了flexbox布局来搞定的。

i18n国际化

在网页端支持i18n国际化,这是确实是以前未接触过的地方。考虑到这个项目有大量散落的HTML文本中硬编码了中文文字,而又没有使用类似于Angular、React这种先进的技术来支持模板,因此就需要自行解决。最开始思考了使用服务端渲染的解决方案(即NW.js当作浏览器,本地起node使用express当作服务端,来返回渲染好对应国际化后的HTML),但是遇到了问题,当作纯浏览器后,NW.js无法再使用node端的本地包,这也就意味着无法调用外部的命令行工具(相当于RPC了)。因此这种方案不可行。

再经过尝试后,最后使用的解决方案,是引入了node-i18n和模板引擎(这里用的是doT)。在项目目录下准备好i18n的文本资源(框架支持的是JSON格式)。然后在NW.js应用启动时加载一个空body的页面,执行JS来获取i18n后的字符串,再将这些字符串渲染到只有body的模板中,最后把国际化完成后的HTML body插入到原始的页面的body中。整个过程没有多余的开销(避免了模板未渲染前被显示出来,而且可以缓存模板结果,因为实际上给定一种locale,模板生成的HTML是固定的)。

感受

其实现在看看自己平时用到的应用,AtomVS CodeGitKraken钉钉,这些看起来已经足够复杂,也都能够用这种前端技术栈构建起来了。以前自己如果提到跨平台桌面客户端应用,第一反应就是Qt,不过现在看来,如果对前端技术栈有所了解,对性能和实时性要求不高,是可以使用Electron或者NW.js这种框架来构建。虽然曾经见过有人批判这些框架(体积庞大-打包了Chromium和Node;内存占用高,效率低下-WebKit渲染而不是原生UI组件),reddit上甚至有讨论说这是新一代的Adobe Flash。

但我个人看来,不排斥这样的框架,只是感觉如今的解决方案并不是十分完美,这些前端栈技术写的客户端最大的问题其实是代码复用问题,基本上是各家有自己的一套组件,而且很多解决方案很Trick。我觉得更为理想的情况,是能够提供一套完整的解决方案,包含了开箱即用的UI组件(并非指Bootstrap这种通用Web UI组件,而是专门针对桌面客户端优化的,符合客户端的交互方式),能够开发,构建,测试,打包一站式自动处理,足够多的Native桥接(这也是一大痛点,见过一些应用又回过头在Electron里面使用Flash),更多的优化,比如共享Chromium容器-不必每个应用的带上200MB的运行环境。

总体来说,Electron或者NW.js这些框架的前途还是比较光明的,毕竟传统意义上的桌面应用开发成本还是太高,尤其是互联网公司的产品,追求跨平台的情况下,在成本,人力还有技术难点考虑来看,也是一个不错的选择。

总结

其实,这三个开源项目都是属于一时兴起才去贡献的,并不是为了而去专门寻找的,至于为什么都是WebP相关,或许真的是巧合吧。参与这些开源项目,虽然花费了一定的时间精力,但是获得的知识面上的提升确实非常大,包括但不限于:WebP规范Accelerate Framework跨平台C++移植Python单元测试CI配置NW.js前端i18n

说实话,参与开源项目的时候,你会发现一些社区是很有意思的,你能够和不认识的人去合作,还能够直观感受到其他人对项目的关注,更能够接触很多你之前从没有接触过的技术栈。我不能说自己是一个愿意花费大量个人时间去贡献开源事业的人,但是其实很多项目参与门槛不是那么高,无论是你自己平时用到的软件、类库,甚至是一个小工具、脚本、翻译、教程,都可以试着参与一下。我觉得程序员的知识,并不是为了单纯为了打工搬砖,能够把自己的想法与他人分享也是一个相当大的乐趣,不是吗?

❌
❌