最近的项目有一个需求:动态替换一个apk中的文件,然后进行签名。
实现功能
因为我们知道,apk其实就是zip压缩包,因此我直接使用Java的ZipInputStream和ZipOutputStream修改,然后输出后进行签名不就可以了吗?
事实也的确如此,但是我踩的第一个坑就是:apk中对某些文件的对齐方式有严格要求,以及部分文件需要标记为未压缩
这个我一开始尝试手动处理,但似乎复杂度非常高,遂放弃,使用官方工具 https://developer.android.com/tools/zipalign 它会帮我们自动处理好所有的特殊格式要求
随后再使用apksigner就可以了
性能优化
然而,我需要修改的apk是一个unity游戏,里面的各类资源文件等等数量非常多,使用java自带的zip处理类性能非常差劲
随后我想到了使用7zip,经过搜索没想到还真的有java的binding库,以下是使用7zipJBinding库的代码:
package top.skidder.backend.service;
import java.io.Closeable;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import net.sf.sevenzipjbinding.*;
import net.sf.sevenzipjbinding.impl.OutItemFactory;
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;
import net.sf.sevenzipjbinding.impl.RandomAccessFileOutStream;
import net.sf.sevenzipjbinding.util.ByteArrayStream;
public class ApkBuildUtil {
public static void buildUnsignedApk(Path baseApk, Path outputApk, String gameId) throws IOException {
if (outputApk.getParent() != null && !Files.exists(outputApk.getParent())) {
Files.createDirectories(outputApk.getParent());
}
initSevenZip();
RandomAccessFile inRaf = null;
RandomAccessFile outRaf = null;
IInArchive inArchive = null;
IOutUpdateArchive<IOutItemAllFormats> outArchive = null;
List<Closeable> closeables = new ArrayList<>();
try {
inRaf = new RandomAccessFile(baseApk.toFile(), "r");
closeables.add(inRaf);
IInStream inStream = new RandomAccessFileInStream(inRaf);
inArchive = SevenZip.openInArchive(null, inStream);
closeables.add(inArchive);
outRaf = new RandomAccessFile(outputApk.toFile(), "rw");
outRaf.setLength(0);
closeables.add(outRaf);
outArchive = inArchive.getConnectedOutArchive();
int itemCount = inArchive.getNumberOfItems();
int sdkIndex = -1;
for (int i = 0; i < itemCount; i++) {
String path = (String) inArchive.getProperty(i, PropID.PATH);
if (path != null) {
String norm = path.replace('\\', '/');
if ("assets/sdk.data".equals(norm)) {
sdkIndex = i;
break;
}
}
}
final int finalSdkIndex = sdkIndex;
final byte[] sdkBytes = gameId.getBytes(java.nio.charset.StandardCharsets.UTF_8);
IOutCreateCallback<IOutItemAllFormats> callback = new IOutCreateCallback<>() {
@Override
public void setOperationResult(boolean operationResultOk) throws SevenZipException { }
@Override
public void setTotal(long total) throws SevenZipException { }
@Override
public void setCompleted(long complete) throws SevenZipException { }
@Override
public IOutItemAllFormats getItemInformation(int index, OutItemFactory<IOutItemAllFormats> outItemFactory) throws SevenZipException {
if (finalSdkIndex >= 0) {
if (index != finalSdkIndex) {
return outItemFactory.createOutItem(index);
}
IOutItemAllFormats item = outItemFactory.createOutItemAndCloneProperties(index);
item.setUpdateIsNewData(true);
item.setDataSize((long) sdkBytes.length);
return item;
} else {
if (index < itemCount) {
return outItemFactory.createOutItem(index);
} else {
IOutItemAllFormats item = outItemFactory.createOutItem();
item.setUpdateIsNewProperties(true);
item.setPropertyPath("assets/sdk.data");
item.setUpdateIsNewData(true);
item.setDataSize((long) sdkBytes.length);
return item;
}
}
}
@Override
public ISequentialInStream getStream(int i) throws SevenZipException {
if (finalSdkIndex >= 0) {
return (i == finalSdkIndex) ? new ByteArrayStream(sdkBytes, true) : null;
} else {
return (i == itemCount) ? new ByteArrayStream(sdkBytes, true) : null;
}
}
};
int outItems = (sdkIndex >= 0) ? itemCount : (itemCount + 1);
outArchive.updateItems(new RandomAccessFileOutStream(outRaf), outItems, callback);
} catch (SevenZipException e) {
throw new IOException("SevenZip error: " + e.getMessage(), e);
} catch (Exception e) {
throw new IOException("Update APK failed: " + e.getMessage(), e);
} finally {
for (int i = closeables.size() - 1; i >= 0; i--) {
try { closeables.get(i).close(); } catch (Throwable ignored) {}
}
}
}
public static void deleteRecursively(Path path) throws IOException {
if (!Files.exists(path)) return;
Files.walk(path)
.sorted((a, b) -> b.getNameCount() - a.getNameCount())
.forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {}
});
}
private static volatile boolean sevenZipInited = false;
private static synchronized void initSevenZip() throws IOException {
if (!sevenZipInited) {
try {
SevenZip.initSevenZipFromPlatformJAR();
sevenZipInited = true;
} catch (SevenZipNativeInitializationException e) {
throw new IOException("Failed to init SevenZip: " + e.getMessage(), e);
}
}
}
}
使用这个库后,生成一个200M大小的apk包在我的2核心服务器上耗时仅有2s左右,非常快
包名修改
这里仅做演示。只修改AndroidManifest里的包名,可以确保共存,但对于某些Apk来说可能有兼容性问题
apk里面的 AndroidManifest.xml 是经过编译的,因此当然不能直接修改
简单方式
最简单的方式是使用apktool
apktool d test.apk -o out
然后修改AndroidManifest.xml
再用apktool打包回去
优化
我们知道,AndroidManifest其实和class文件的结构有点像,它内部也有一个字符串池(String Pool)
既然我们只需要修改一些包名,那么是不是我们也可以直接修改字符串池呢?
答案是当然可以,只不过我目前没有找到现有的工具,所以需要我们自己实现。
这个工具的代码和解析我会在另外的文章中介绍