导出微信的自定义表情?简单!

2 阅读6分钟

一、背景

最近公司群里有人反馈说如何能够将微信的表情包导出来,要添加到内部的沟通软件上使用。群里的同学纷纷出招,有人说通过网页文件助手发送,然后右键保存导出。我也尝试了一下这个方案,确实可以导出。只需要发送完成后在网页上右键保存到本地即可(动图的话需要保存时添加.gif后缀)。

filehelper.weixin.qq.com/

image.png

这个方案确实是一种解决方法,但是缺陷也比较明显,就是只能一个个去操作保存,效率较低。如果需要批量导出,就略显繁琐。如何能够批量导出微信的表情包,就是接下来本篇文章要讲的内容。

二、方案调研

通过搜索引擎搜了一下,在mac平台下有这样一个解决方案。

blog.jogle.top/2022/08/14/…

核心方案就是访问mac版微信在沙盒的缓存文件fav.archive,找到里面的表情包url地址,然后粘贴到浏览器访问下载。

这个沙盒路径如下,打开后找到一个比较长字符串命名的文件夹,里面有很多子文件夹,其中有个文件夹叫Stickers,这里就是存放fav的地方了。

open ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application\ Support/com.tencent.xinWeChat/2.0b4.0.9/

image.png

fav.archive本质是一个二进制的plist数据,里面的数据结构如下。其中在$objects这个key下面存放了表情包的相关数据,当然也包括一些其他类型的数据。我们需要的就是以http开头的网络图片数据。

image.png

测试了下直接把url地址复制出来到浏览器,图片直接打开是没问题的,也就是说url本身是存在鉴权信息的。至于鉴权有效期是多久就不清楚了。反正是微信负责维护他的有效期,我们直接取这个地址就行了。url结构如下。

vweixinf.tc.qq.com/110/20401/s…

image.png

所以只要我们遍历$objects这个数组,取出http开头的字符串,然后下载图片不就ok了吗。一个批量导出表情的工具不就有了吗。毕竟我可是尊贵的loser开发。

037.png

三、mac软件开发

首先画个流程图,简单介绍一下实现流程。

graph TD
id1([app启动]) --> 访问fav.archive --> 读取plist数据到内存 --> 取出$objects数据 --> 添加http开头字符串到数组 --> 遍历数组加载图片 --> 图片批量导出

软件效果图如下。默认读取~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application\ Support/com.tencent.xinWeChat/2.0b4.0.9/xxxx/Stickers/fav.archive的数据。如果没有读取到,也支持在下面自己选择fav.archive文件。

image.png

3.1 画UI

基于AppKit的mac原生代码开发非常痛苦,AppKit并不像UIKit那样强大,很多功能和api用起来都很别扭,所以mac原生开发简单的页面最好还是使用storyboard来实现。

image.png

页面本身不复杂,一个NSCollecionView用来展示表情数据,可以跟随窗口大小拖动自适应不同列数展示,设置datasource为Viewcontroller;两个操作按钮,用来选择archive文件和导出表情操作;一个error label用来展示使用过程的异常;最后有个progressView用来展示表情导出进度。这样一个表情查看器的UI就写好了。

3.2 写逻辑

3.2.1 框架集成

图片的展示及下载当时必不可少SDWebImage,AFNetworking也是有可能用到的,这俩框架先通过pod集成到工程中。

platform :osx, '10.13'

target "StickerViewer" do
    pod 'AFNetworking'
    pod 'SDWebImage'
end

3.2.2 文件读取

通过NSFileManager对微信的沙盒文件进行访问。这里有一点需要注意的是必须使用完整路径进行访问,如果直接使用~Library的形式访问,NSFileManager会访问报错(这个问题当时折腾了好久才发现)。

NSString *username = NSUserName();
NSString *basePath = [NSString stringWithFormat:@"/Users/%@/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9", username];

NSString *stickerComponent = @"Stickers";
NSString *stickerPath = nil;

NSError *error;

NSFileManager *fileManager = [NSFileManager defaultManager];

NSArray *contents = [fileManager contentsOfDirectoryAtPath:basePath error:&error];

找到2.0b4.0.9目录后,接下来就需要找到自己那一长串用户id的目录,再定位到Stickers目录。这里没什么好的方案,遍历2.0b4.0.9目录,查看是否存在Stickers目录就可以了。

NSString *stickerComponent = @"Stickers";
for (NSString *item in contents) {
    NSString *currentPath = [[basePath stringByAppendingPathComponent:item] stringByAppendingPathComponent:stickerComponent];
    BOOL isDir;
    if ([fileManager fileExistsAtPath:currentPath isDirectory:&isDir] && isDir) {
        //找到路径
        stickerPath = [[basePath stringByAppendingPathComponent:item] stringByAppendingPathComponent:stickerComponent];
        break;
    }
}

if (stickerPath.length) {
    NSString *stickerFavPath = [stickerPath stringByAppendingPathComponent:@"fav.archive"];
    if ([fileManager fileExistsAtPath:stickerFavPath isDirectory:nil]) {
        //加载xml数据
        self.favData = [NSData dataWithContentsOfFile:stickerFavPath];
        [self parsePlistData];
    }
}

添加bookmark: 添加bookmark的作用是只会弹出一次授权访问的弹窗,不然每次打开app都会提示授权,比较麻烦。

//保存bookmark
NSURL *basePathUrl = [NSURL fileURLWithPath:basePath];
NSError *bookMarkError = nil;

NSData *bookmarkData =[basePathUrl bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope includingResourceValuesForKeys:nil relativeToURL:nil error:&bookMarkError];
if (bookMarkError) {
    NSLog(@"bookmark出错:%@", bookMarkError);
} else {
    [[NSUserDefaults standardUserDefaults] setObject:bookmarkData forKey:@"bookmarkdata"];
    NSLog(@"bookmarkdata保存成功");
}

//访问bookmark
BOOL bookmarkDataIsStale;
NSURL *allowedUrl = [NSURL URLByResolvingBookmarkData:self.bookMarkData options:NSURLBookmarkResolutionWithSecurityScope|NSURLBookmarkResolutionWithoutUI relativeToURL:nil bookmarkDataIsStale:&bookmarkDataIsStale error:NULL];
[allowedUrl startAccessingSecurityScopedResource];

//读取文件的代码

3.2.3 文件解析

通过NSPropertyListSerialization对数据进行解析,获取到表情数组self.stickersArray

NSError *error;
NSDictionary *plistObject = [NSPropertyListSerialization propertyListWithData:self.favData options:NSPropertyListImmutable format:NULL error:&error];

if (plistObject) {
    self.errorLabel.hidden = YES;
    NSArray *stickersData = plistObject[@"$objects"];
    if (stickersData && [stickersData isKindOfClass:NSArray.class]) {
        //遍历数据
        for (id item in stickersData) {
            if ([item isKindOfClass:NSString.class] && [(NSString *)item hasPrefix:@"http"]) {
                [self.stickersArray addObject:item];
            }
        }
    }
} else {
    self.errorLabel.hidden = NO;
    self.errorLabel.stringValue = [NSString stringWithFormat:@"plist解析失败:%@", error.localizedDescription];
}

//视图刷新
[self.collectionView reloadData];

3.2.4 图片加载

使用SDWebImage加载图片到collectionview的item上。

[self.stickerImageView sd_setImageWithURL:[NSURL URLWithString:urlString]
                             placeholderImage:[NSImage imageNamed:@"sticker_holder"]
                                    completed:^(NSImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
        //图片加载完毕
    }];

3.2.5 图片批量导出

使用loadImageWithURL:方法可以优先使用磁盘及内存缓存将图片导出。

[[SDWebImageManager sharedManager] loadImageWithURL:[NSURL URLWithString:imageUrlString]
                                            options:SDWebImageRetryFailed
                                           progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
    //下载进度
} completed:^(NSImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {

    //图片数据
    NSData *imageData = data;
    if (imageData) {
        //图片保存
        NSString *fileName = [NSString stringWithFormat:@"%03ld.%@", i, image.sd_isAnimated ? @"gif" : @"png"];
        NSString *imagePath = [stickerPath stringByAppendingPathComponent:fileName];
        [imageData writeToFile:imagePath atomically:YES];

        //更新进度等
    }
}];

实际测试下来上面的方法部分表情数据会只返回NSImage对象,未返回NSData对象,尝试用NSImage对象转换为NSData,无论是转成位图还是其他形式,转换出来的图片都是偏小的,gif也不会动,所以只能通过SDWebImageDownloader对这种异常的图片重新进行下载(测试下来使用这种方式后图片下载下来是正常的)。

[[SDWebImageDownloader sharedDownloader] downloadImageWithURL:[NSURL URLWithString:imageUrlString]
                                                      options:SDWebImageDownloaderAllowInvalidSSLCertificates|SDWebImageDownloaderHighPriority
                                                     progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
    //下载进度
}
                                                    completed:^(NSImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, BOOL finished) {
    //图片数据
    NSData *imageData = data ?: [image TIFFRepresentation];

    //图片保存
    NSString *fileName = [NSString stringWithFormat:@"%03ld.%@", index, image.sd_isAnimated ? @"gif" : @"png"];
    NSString *imagePath = [savePath stringByAppendingPathComponent:fileName];
    [imageData writeToFile:imagePath atomically:YES];
    
    if (successCount == self.stickersArray.count) {

        //导出完成
        self.progressView.hidden = YES;
        [FWAlertUtils showAlertWithTitle:@"导出完成"];

        //打开文件夹
        [[NSWorkspace sharedWorkspace] openURL:[NSURL fileURLWithPath:savePath]];
    }
}];

以上就是所有的核心代码了,可以发现也是比较简单的。最终一个可以访问读取fav.archive文件的微信表情查看器就实现好了。

image.png

导出后的表情按序号放在文件夹中。

image.png

四、一些感想

其实最开始我的思路是,既然mac版微信表情面板是把所有的表情加载好的,那只需要找到沙盒中他的表情缓存然后导出就可以了。然而鸡贼的wx对他的表情数据进行了加密。其中fav.archive文件的同级目录下有个文件夹叫Persistence,里面存放了很多文件,这个文件夹整体大小200M左右,与我自己导出的所有表情170M大小差不多。但这个数据是无法直接打开的,修改后缀为png也是无法打开的。这些文件一种可能是一种本地存储的切片文件,另一种可能是加密后的单个表情的二进制数据。这个大家有兴趣可以研究一下。

image.png

最后,该软件已经开源到github中,欢迎下载体验及star。有问题欢迎大家一起交流。 github.com/zhouxing531…