背景:
正在開發的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就行。
多人同時下載搶占文件導致的線程安全問題:
這個問題暫時的考慮方案是每當有下載請求就會先復制一份,將復制的文件進行修改,客戶端下載成功再刪除。
但是未做測試,不知是否會產生問題。
思考:
- 服務端和客戶端不一樣,服務端的任何請求都需要考慮線程同步問題;
- 既然客戶端可以獲取到安裝包,則其實也可以通過修改包名來進行業務人員信息的傳遞;
- 利用該方法可以傳遞其他數據用來實現其他一些功能,不局限於業務人員的信息。