背景:
正在开发的APP需要记录业务员与客户的绑定关系。具体应用场景如下:
由流程图可知,并没有用户填写业务人员信息这一步,因此在用户下载的APP中就已经携带了业务人员的信息。
由于业务人员众多,不可能针对于每一个业务人员单独生成一个安装包,于是就有了动态修改APP安装包的想法。
原理:
Android使用的apk包的压缩方式是zip,与zip有相同的文件结构(zip文件结构见zip文件格式说明),在zip的EOCD区域中包含一个Comment区域。
如果我们能够正确修改该区域,就可以在不破坏压缩包、不重新打包的前提下快速给apk文件写入自己想要的数据。
apk默认情况下没有Comment,所以Comment length的short两个字节为0,我们需要把这个值修改为我们的Comment长度,并把Comment追加到后面即可。
整体过程:
服务端实现:
实现下载接口:
1 @RequestMapping(value = "/download", method = RequestMethod.GET) 2 public void download(@RequestParam String token, HttpServletResponse response) throws Exception { 3 4 // 获取干净的apk文件 5 Resource resource = new ClassPathResource("app-release.apk"); 6 File file = resource.getFile(); 7 8 // 拷贝一份新文件(在新文件基础上进行修改) 9 File realFile = copy(file.getPath(), file.getParent() + "/" + new Random().nextLong() + ".apk"); 10 11 // 写入注释信息 12 writeApk(realFile, token); 13 14 // 如果文件名存在,则进行下载 15 if (realFile != null && realFile.exists()) { 16 // 配置文件下载 17 response.setHeader("content-type", "application/octet-stream"); 18 response.setContentType("application/octet-stream"); 19 // 下载文件能正常显示中文 20 response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(realFile.getName(), "UTF-8")); 21 22 // 实现文件下载 23 byte[] buffer = new byte[1024]; 24 FileInputStream fis = null; 25 BufferedInputStream bis = null; 26 try { 27 fis = new FileInputStream(realFile); 28 bis = new BufferedInputStream(fis); 29 OutputStream os = response.getOutputStream(); 30 int i = bis.read(buffer); 31 while (i != -1) { 32 os.write(buffer, 0, i); 33 i = bis.read(buffer); 34 } 35 System.out.println("Download successfully!"); 36 } catch (Exception e) { 37 System.out.println("Download failed!"); 38 } finally { 39 if (bis != null) { 40 try { 41 bis.close(); 42 } catch (IOException e) { 43 e.printStackTrace(); 44 } 45 } 46 if (fis != null) { 47 try { 48 fis.close(); 49 } catch (IOException e) { 50 e.printStackTrace(); 51 } 52 } 53 } 54 } 55 }
拷贝文件:
1 private File copy(String source, String target) { 2 Path sourcePath = Paths.get(source); 3 Path targetPath = Paths.get(target); 4 5 try { 6 return Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING).toFile(); 7 } catch (IOException e) { 8 e.printStackTrace(); 9 } 10 return null; 11 }
往apk中写入信息:
1 public static void writeApk(File file, String comment) { 2 ZipFile zipFile = null; 3 ByteArrayOutputStream outputStream = null; 4 RandomAccessFile accessFile = null; 5 try { 6 zipFile = new ZipFile(file); 7 8 // 如果已有comment,则不进行写入操作(其实可以先擦除再写入) 9 String zipComment = zipFile.getComment(); 10 if (zipComment != null) { 11 return; 12 } 13 14 byte[] byteComment = comment.getBytes(); 15 outputStream = new ByteArrayOutputStream(); 16 17 // comment内容 18 outputStream.write(byteComment); 19 // comment长度(方便读取) 20 outputStream.write(short2Stream((short) byteComment.length)); 21 22 byte[] data = outputStream.toByteArray(); 23 24 accessFile = new RandomAccessFile(file, "rw"); 25 accessFile.seek(file.length() - 2); 26 27 // 重写comment实际长度 28 accessFile.write(short2Stream((short) data.length)); 29 // 写入comment内容 30 accessFile.write(data); 31 } catch (IOException e) { 32 e.printStackTrace(); 33 } finally { 34 try { 35 if (zipFile != null) { 36 zipFile.close(); 37 } 38 if (outputStream != null) { 39 outputStream.close(); 40 } 41 if (accessFile != null) { 42 accessFile.close(); 43 } 44 } catch (Exception e) { 45 e.printStackTrace(); 46 } 47 } 48 }
其中:
1 private static byte[] short2Stream(short data) { 2 ByteBuffer buffer = ByteBuffer.allocate(2); 3 buffer.order(ByteOrder.LITTLE_ENDIAN); 4 buffer.putShort(data); 5 buffer.flip(); 6 return buffer.array(); 7 }
客户端实现:
获取comment信息并写入TextView:
1 @Override 2 protected void onCreate(Bundle savedInstanceState) { 3 super.onCreate(savedInstanceState); 4 setContentView(R.layout.activity_main); 5 6 TextView textView = findViewById(R.id.tv_world); 7 8 // 获取包路径(安装包所在路径) 9 String path = getPackageCodePath(); 10 // 获取业务员信息 11 String content = readApk(path); 12 13 textView.setText(content); 14 }
读取comment信息:
1 public String readApk(String path) { 2 byte[] bytes = null; 3 try { 4 File file = new File(path); 5 RandomAccessFile accessFile = new RandomAccessFile(file, "r"); 6 long index = accessFile.length(); 7 8 // 文件最后两个字节代表了comment的长度 9 bytes = new byte[2]; 10 index = index - bytes.length; 11 accessFile.seek(index); 12 accessFile.readFully(bytes); 13 14 int contentLength = bytes2Short(bytes, 0); 15 16 // 获取comment信息 17 bytes = new byte[contentLength]; 18 index = index - bytes.length; 19 accessFile.seek(index); 20 accessFile.readFully(bytes); 21 22 return new String(bytes, "utf-8"); 23 } catch (FileNotFoundException e) { 24 e.printStackTrace(); 25 } catch (IOException e) { 26 e.printStackTrace(); 27 } 28 return null; 29 }
其中:
1 private static short bytes2Short(byte[] bytes, int offset) { 2 ByteBuffer buffer = ByteBuffer.allocate(2); 3 buffer.order(ByteOrder.LITTLE_ENDIAN); 4 buffer.put(bytes[offset]); 5 buffer.put(bytes[offset + 1]); 6 return buffer.getShort(0); 7 }
遇到的问题:
修改完comment之后无法安装成功:
最开始遇到的就是无法安装的问题,一开始以为是下载接口写的有问题,经过多次调试之后发现是修改完comment之后apk就无法安装了。
查询谷歌官方文档可知
因此,只需要打包的时候签名方式只选择V1不选择V2就行。
多人同时下载抢占文件导致的线程安全问题:
这个问题暂时的考虑方案是每当有下载请求就会先复制一份,将复制的文件进行修改,客户端下载成功再删除。
但是未做测试,不知是否会产生问题。
思考:
- 服务端和客户端不一样,服务端的任何请求都需要考虑线程同步问题;
- 既然客户端可以获取到安装包,则其实也可以通过修改包名来进行业务人员信息的传递;
- 利用该方法可以传递其他数据用来实现其他一些功能,不局限于业务人员的信息。