前言
App开发测试过程中,我们会把安装包传到各种第三方的内测分发平台方便下载。这些平台或多或少有这样那样的限制,比如下载量啊、付费啊、不能方便找到历史版本啊。还有一方面,我们经常会打Debug版本的包方便调试,又不希望Debug包流传到外部去,这样就很有必要自己搭一个下载平台,于是就有了这个项目(github地址)。
技术调研
怎么下载
先说安卓,apk文件通过最简单的http/ftp下载就可以安装了,略过。
iOS稍微复杂一点,需要两步才能完成。
第一,下载链接必须是这样的格式
itms-services://?action=download-manifest&url=一个plist文件的地址
第二,plist内容如下
<?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>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key>
<string>software-package</string>
<key>url</key>
<string>ipa文件的地址</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>bundle-identifier</key>
<string>bundleID</string>
<key>bundle-version</key>
<string>1.0</string>
<key>kind</key>
<string>software</string>
<key>title</key>
<string>AppTitle</string>
</dict>
</dict>
</array>
</dict>
</plist>
其中,最重要的就是ipa文件的地址,要求必须是https协议,那就需要SSL证书,幸运的是我们可以信任自签名的证书。下载的过程就是这样,当然我们希望这个链接和plist的生成是自动完成的。
自签名证书
包信息提取
单单只能下载还不够,我们希望看到更多的信息:App名字、版本号、build号、更新时间、图标等。这些信息虽然可以留给上传者在上传的时候一并带上,但是作为有追求的程序员,把方便留给别人的最基本的,因此我们要从ipa/apk中提取这些信息。
无论是ipa还是apk,本质都是zip压缩文件。
对于iOS的ipa,包信息都放在Info.plist中,主要有CFBundleVersion、CFBundleIdentifier、CFBundleShortVersionString、CFBundleName等。图标文件的名字也是固定的,只要解压就可以得到。不过,苹果对png图片进行了了自定义的pngcrush压缩,有压缩自然就有还原工具pngdefry。
对于Android的apk,解压后还能看到AndroidManifest.xml,但是里面的内容经过编码显示为乱码,不方便查看,需要借助开发工具aapt(Android Asset Packaging Tool),方法如下
aapt dump badging apkPath
输出的文本格式如下,不是标准的歌声,需要手动转换一下。
package: name='com.jianshu.haruki' versionCode='16070101' versionName='1.11.2'
sdkVersion:'14'
targetSdkVersion:'22'
...
application: label='简书' icon='res/drawable-hdpi-v4/icon_jianshu_new.png'
...
找轮子
程序员有一个习惯,需要某个东西的时候会先一番搜索,直接用别人写好的,用着用着发现别人写的东西有这样那样的不足,然后撸起袖子自己造一个。这次也不例外,我在github上找到了一个ios-ipa-server,它的特点是简单,ipa文件存储在一个目录下,没有数据库,包信息只有上传时间(其实就是文件更新时间),不能对app归类,只靠文件名区别,不支持上传,如下图:
浏览器访问下载页面时,后端实时解析包信息、解压icon图片,这样做效率是非常低的。
这么多不足我们就有了造轮子的理由了。
自己造一个
既然ios-ipa-server是基于node-express写的,正好我没写过nodejs,那就在它的基础上继续写吧,借机学(zhuang)习(bi)一下。
整个项目的结构是这样的,提供四个API:包上传、获取所有App最新版本、获取某个App的所有版本、动态生成plist文件,数据存储使用sqlite3。
包上传
接口设计如下:
path:
POST /upload
param:
package:安装包文件
response:
{
id: 6,
guid: "46269d71-9fda-76fc-3442-a118d6b08bf1",
bundleID: "com.jianshu.Hugo",
version: "2.11.4",
build: "1608051045",
icon: "https://10.20.30.233:1234/icon/46269d71-9fda-76fc-3442-a118d6b08bf1.png",
name: "Hugo",
uploadTime: "2016-12-01 20:50:05",
platform: "ios",
url: "itms-services://?action=download-manifest&url=https://10.20.30.233:1234/plist/46269d71-9fda-76fc-3442-a118d6b08bf1"
}
后端需要拿到安装包,提取出包信息和png图标图片,然后插入到数据库中,最后存储安装包文件和png图片,这也是最关键、最复杂的一个API。
app.post('/upload', function(req, res) {
var form = new multiparty.Form();
form.parse(req, function(err, fields, files) {
var obj = files.package[0];
var tmp_path = obj.path;
parseAppAndInsertToDb(tmp_path, info => {
storeApp(tmp_path, info["guid"], error => {
if (error) {
errorHandler(error,res)
}
})
console.log(info)
res.send(info)
}, error => {
errorHandler(error,res)
});
});
});
接收表单信息用到了multiparty模块,parseAppAndInsertToDb
内部完成了包信息的提取和存储,storeApp
存储包文件。
parseAppAndInsertToDb
的实现如下,
function parseAppAndInsertToDb(filePath, callback, errorCallback) {
var guid = Guid.create().toString();
var parse, extract
if (path.extname(filePath) === ".ipa") {
parse = parseIpa
extract = extractIpaIcon
} else if (path.extname(filePath) === ".apk") {
parse = parseApk
extract = extractApkIcon
}
Promise.all([parse(filePath),extract(filePath,guid)]).then(values => {
var info = values[0]
info["guid"] = guid
excuteDB("INSERT INTO info (guid, platform, build, bundleID, version, name) VALUES (?, ?, ?, ?, ?, ?);",
[info["guid"], info["platform"], info["build"], info["bundleID"], info["version"], info["name"]],function(error){
if (!error){
callback(info)
} else {
errorCallback(error)
}
});
}, reason => {
errorCallback(reason)
})
}
首先根据文件后缀名判断安装包类型,因为ipa和apk的处理逻辑不一样,所以分别对应两个方法,包信息的提取和icon提取可以同时进行,所以这里用了Promise.all
。parseIpa
和parseApk
就是包信息的提取。extractApkIcon
和extractIpaIcon
则是icon的提取,extractIpaIcon
多了一步还原png图片的处理。
parseIpa
用到了ipa-extract-info
模块,parseApk
则使用了apk-parser3
,代码都非常简单。详细可进入github地址。
其他
其他三个API则比较简单了,无非就是根据参数取数据,不再赘述。
集成和使用
安装步骤非常简单,首先需要安装node,有了node之后只要一行命令
npm install -g ipapk-server
安装完成之后输入命令
ipapk-server
手机浏览器访问https://ip:port 即可打开下载页面
App的信息获取都设计成了API,提供给开发者更灵活的接入方式,可以做web页面,也可以做成App,我的好朋友mask(人格分裂术)贡献了不少工作,完成默认的web下载页面。
更详细的内容请参考github。
写在最后
简书作为一个优质原创内容社区,拥有大量优质原创内容,提供了极佳的阅读和书写体验,吸引了大量文字爱好者和程序员。简书技术团队在这里分享技术心得体会,是希望抛砖引玉,吸引更多的程序员大神来简书记录、分享、交流自己的心得体会。这个专题以后会不定期更新简书技术团队的文章,包括Android、iOS、前端、后端等等,欢迎大家关注。
网友评论
如下:
pngdefry : seen 1 file(s), wrote 1 file(s)
/usr/local/lib/node_modules/ipapk-server/ipapk-server.js:302
var data = info[0];
^
TypeError: Cannot read property '0' of undefined
at /usr/local/lib/node_modules/ipapk-server/ipapk-server.js:302:22
at f (/usr/local/lib/node_modules/ipapk-server/node_modules/once/once.js:25:25)
at ZipFile.<anonymous> (/usr/local/lib/node_modules/ipapk-server/node_modules/ipa-extract-info/index.js:52:33)
at emitNone (events.js:106:13)
at ZipFile.emit (events.js:208:7)
at Immediate._onImmediate (/usr/local/lib/node_modules/ipapk-server/node_modules/yauzl/index.js:246:12)
at runCallback (timers.js:789:20)
at tryOnImmediate (timers.js:751:5)
at processImmediate [as _immediateCallback] (timers.js:722:5)
exit(1)
欢迎订阅《iOS技术分享》https://toutiao.io/subjects/58931