最近看到公司IOS的同事做了一个app打包工具给QA使用,极大的方便了QA的工作,也给开发节省了不少精力,不需要频繁的接收QA的要求给QA打包新app做测试,防止编程思路被打包这些琐事给打断。
为了编写方便和跨平台应用,我使用了网页版的交互方式,使用tomcat 8做服务器,这样可以让任意一台手机和电脑通过浏览器就可以轻松的打包然后收到相应的.app文件,界面大概是这个样子
主要的功能是这样的
1、可以自由切换分支,分支号通过下拉列表的形式显示在网页上
2、可以自由切换服务器环境,比如测试服,开发服,正式服等等,上图的staging,dev,live就属于服务器
3、可以自动拉取git分支最新代码
4、可以自动进行apk的签名,apk对齐
大体思路是这样:
1、关于切换分支
因为不同git分支的上的代码不一样,有些依赖库也不一样,通过git命令行直接克隆分支然后用gradle编译是不行的,因为直接克隆下来的代码,尤其是一些iml配置文件在本机是不能直接用的,而且有些依赖库是以com.google.xxx.xxx这样的方式写在gradle.xml里的,这些依赖库本身需要联网进行下载,另外不同的分支用的gradle版本也不一样,需要下载对应的gradle,所以使用一个git 仓库通过check分支来切换这种方式行不通。所以我使用的多个git仓库,每个git仓库放一个分支的代码,切换分支实际上就是通过切换不同的git仓库实现的。这样做之后,下载依赖库和重写iml文件就交给Android
Studio来进行,说白了就是克隆完一个分支之后,先用Android Studio先clean一遍,再用AS打一个apk,这样这个git仓库就和本机的配置契合,可以被命令行打包了。
2、关于切换环境
因为这个项目里服务器的ip地址写死在了.java文件里面,所以只要修改相关java文件里面的ip地址就可以实现服务器的切换。所以我在这里是将各个写有不同ip地址的java文件放在git仓库之外,当打包时根据要打包的环境动态替换项目目录中的.java文件实现环境的切换。
3、关于pull代码,签名,对齐,这些通过直接的git命令和gradle命令执行就好了
4、关于进度的提示:
因为gradle在编译的时候对CPU和内存的开销很大,所以一次只能有一个编译进程执行,所以我就把进度直接用一个静态保存了,获取进度直接获取这个静态的变量的值就行。
在web端就做的很简单,用http请求每隔两秒进行轮询,没有采用高大尚的socket通讯。
先来复习一下gradle相关的命令行指令吧
打包指令:
首先cd到项目根目录下,就是有gradlew.bat这个文件的那个目录,然后执行(gradle命令比Eclipse打包容易多了)
gradle bulid 或 gradlew build 或 gradlew clean build
制作签名文件指令:
keytool -genkeypair -alias mykeyName -keyalg RSA -validity 100 -keystore mydemo.keystore
myKeyName是生成签名文件的别名,非常重要,100是有效期,mydemo.keystore是签名文件的文件名,执行完这一条指令会让你输入一个密码,注意区别密码和前面“alias”(别名)的区别,不要搞混,当时我就搞混了然后浪费了很多时间,下面三张图显示了alias和password的在eclipse和Android Studio打包签名时的截图,帮助你区别alias和password
对apk进行签名:
jarsigner -verbose -keystore E:\QA\file\key -signedjar app-signed.apk -digestalg SHA1 -sigalg MD5withRSA app-release-unsigned.apk mkeyName
首先cd到存放gradle编译好的未签名的apk的目录下
E:\QA\file\key是签名文件的文件路径, app-signed.apk是签好名之后生成apk的文件名,app-release-unsigned.apk是当前文件夹下未签名apk的文件名 mkeyName是签名文件的别名,输入换行符后,控制台会提示你输入签名文件的密码,如果密码正确就会开始一个文件一个文件的进行签名
-digestalg SHA1 -sigalg MD5withRSA是非常重要的,我使用jdk1.8签名后Android 5.0以上机器可以跑,但是5.0一下的机器就装不上,网上说使用jdk1.6以上版本会出现这个签名问题,所以如果你的电脑上装的是jdk1.6以上版本,别忘了加上这句。
对签名好的文件进行对齐,传说对齐可以让安卓系统访问apk包里的资源更快,具体怎样没试过,我对齐apk以后发现apk的大小变大了
首先cd到签名好的apk文件的目录下
zipalign -f -v 4 app-signed.apk app-publish.apk
app-signed.apk是签名好的文件,app-publish.apk是对齐好后生成的新文件
代码时间:
首先是最核心的文件,也就是执行具体命令行指令的java文件
import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.SequenceInputStream; public class PackageNow { //记录每个分支git仓库的地址 public static final String[] BRANCH_0 = {"E:\\QA\\master\\app3","master"};//第0个分支的相关信息,第一个是位置,第二个是分支号,第三个是编译器位置 public static final String[] BRANCH_1 = {"E:\\QA\\branch1\\app3","QRAII-6916"};//第0个分支的相关信息,第一个是位置,第二个是分支号,第三个是编译器位置 public static final String[] BRANCH_2 = {"E:\\QA\\branch2\\app3","QRAII-sprint8"};//第0个分支的相关信息,第一个是位置,第二个是分支号,第三个是编译器位置 public static final String[][] BRANCHES = {BRANCH_0,BRANCH_1,BRANCH_2}; public static final String GIT_ROOT = "http://username:[email protected]:7171/android/app3.git";//外网的git 地址 public static final String APK_PATH = "\\app\\build\\outputs\\apk";//进入项目目录后,gradle编译完成后输出apk的目录 //下面是编译完后生成apk的文件名 public static final String APP_DEBUG = "app-debug.apk";//打了默认签名的apk public static final String APP_SIGNED = "app-signed.apk";//打了正式签名,但是没有4k对齐的apk名字 public static final String APP_PUBLISH = "app-publish.apk";//打了正式签名,且对齐了的apk名字 //用于签名apk的签名文件的路径 public static final String KEY_PATH = "E:\\QA\\file\\key"; //要根据不同服务器环境替换文件的路径 public static final String LIVE_FILE = "E:\\QA\\file\\live\\ApplicationConfigurationEntity.java";//记录live环境的java文件的路径,准备用于替换 public static final String STAGING_FILE = "E:\\QA\\file\\staging\\ApplicationConfigurationEntity.java"; public static final String DEV_FILE = "E:\\QA\\file\\dev\\ApplicationConfigurationEntity.java"; public static final String ALPHA1_FILE = "E:\\QA\\file\\alpha1\\ApplicationConfigurationEntity.java"; public static final String ALPHA2_FILE = "E:\\QA\\file\\alpha2\\ApplicationConfigurationEntity.java"; public static final String ALPHA3_FILE = "E:\\QA\\file\\alpha3\\ApplicationConfigurationEntity.java"; public static final String ALPHA4_FILE = "E:\\QA\\file\\alpha4\\ApplicationConfigurationEntity.java"; public static final String[] environmentNames = {"Staging","Dev","Live"};//获取每个环境的名字,用于给文件命名的,下标是环境的代号 public static final String[] ENVIRONMENTS = {STAGING_FILE,DEV_FILE,LIVE_FILE};//要根据不同环境替换文件的路径 public static final String ENV_FILE = "\\app\\src\\main\\java\\com\\xxxx\\xxxxx\\model\\ApplicationConfigurationEntity.java";//要根据环境不同来动态被的项目里替换的java文件 public static final int totalCount = 997;//git编译控制台输出的总行数,用于判断进度 public static String progress;//记录当前的打包进度 public static boolean isPackaging = false;//记录带当前是否在打包,控制同一时刻只有一个打包进程,节省cpu,内存开销 /** * 根据分支号进行编译 * @param branch * @return 返回值是命令行命令 */ public static String[] packageNow(int branch) {//开始打包 return new String[]{ "cd "+BRANCHES[branch][0],//cd 到项目目录 BRANCHES[branch][0].charAt(0)+":", "gradlew build"//正式进行编译 }; }; /** * 对apk进行签名 * @param branch * @return 返回值是命令行命令 */ public static String[] signKey(int branch){ return new String[]{ "cd "+BRANCHES[branch][0]+APK_PATH, BRANCHES[branch][0].charAt(0)+":", "jarsigner -verbose -keystore "+KEY_PATH+" -signedjar "+APP_SIGNED+" app-release-unsigned.apk keyPassword", "imaginato"//这个是签名文件的密码 }; } /** * 对app进行4k对齐 * @param branch * @return 返回值是命令行命令 */ public static String[] zipAlign(int branch){ return new String[]{ "cd "+BRANCHES[branch][0]+APK_PATH, BRANCHES[branch][0].charAt(0)+":", "zipalign -f -v 4 "+APP_SIGNED+" "+APP_PUBLISH, }; } /** * pull最新代码 * @param branch * @return 返回值是命令行命令 */ public static String[] gitPull(int branch){//pull 一个分支的代码 return new String[]{ "cd "+BRANCHES[branch][0],//cd 到项目目录 BRANCHES[branch][0].charAt(0)+":", "git pull "+GIT_ROOT + " "+BRANCHES[branch][1] }; }; /** * 执行一条命令行指令 * @param orders 命令行命令 * @param callBack 控制台每输出一条反馈,会调用一次回调 * @throws IOException */ public static void runOneRow(String[] orders,ReadLineCallBack callBack) throws IOException{ Process process = Runtime.getRuntime ().exec ("cmd"); SequenceInputStream sis = new SequenceInputStream (process.getInputStream (), process.getErrorStream ()); // next command OutputStreamWriter osw = new OutputStreamWriter (process.getOutputStream ()); InputStreamReader isr = new InputStreamReader (sis, "GBK"); BufferedReader br = new BufferedReader (isr); BufferedWriter bw = new BufferedWriter (osw); for(String s : orders){ System.out.println("待执行的语句是"+s); bw.write (s); bw.newLine (); } bw.flush (); bw.close (); osw.close (); // read String line = null; while (null != ( line = br.readLine () )) { System.out.println (line+"$$"); callBack.readLine(line); } br.close (); isr.close (); process.destroy (); } /** * 控制台每次返回文本后调用的回调接口 * @author Administrator * */ public interface ReadLineCallBack{ void readLine(String line); } /** * 开始打包函数 * @param appLocation 打包完成将apk发送到哪去的文件路径 * @param branch //分支序号 * @param environment //服务器环境序号 * @param sign //是否进行自动签名,如果进行签名那么签名完会再执行一步对齐操作 * @throws IOException */ public static void buildPackage(String appLocation,int branch,int environment,final boolean sign) throws IOException{ System.out.println("即将打包的分支号是"+branch); progress ="正在进行编译"; final int[]rowCount_progress = {0,0};//第0个记录当前是第几行,第1个记录进度百分比 File targetEnvFile = new File(BRANCHES[branch][0]+ENV_FILE);//工作空间里的环境配置文件 File sourceEnvFile = new File(ENVIRONMENTS[environment]);//写好环境的外头的配置文件 copyFile(sourceEnvFile, targetEnvFile); runOneRow(packageNow(branch), new ReadLineCallBack() { public void readLine(String line) { // TODO Auto-generated method stub rowCount_progress[0]++; if(!progress.equals("编译完成") &&!progress.equals("error:编译失败"))progress = "Progress:"+String.valueOf(Math.round((float)rowCount_progress[0]/(float)totalCount*100)+"%"); if(line.startsWith(":")||line.startsWith("Reading")||line.startsWith("Note"))return; if(line.startsWith("BUILD SUCCESSFUL")){ if(!sign)isPackaging = false; System.out.println("编译成功啦"); progress = "编译完成";//如果不需要签名,那么现在已经成功了 }else if(line.startsWith("BUILD FAILED")){ progress = "error:编译失败"; isPackaging = false; } } }); if(!progress.equals("编译完成")){ System.out.println("没有编译成功"+progress); progress = "error:没有编译成功"; return; } System.out.println("一共有"+rowCount_progress[0]+"行"); if(sign){//如果需要签名 System.out.println("准备进行签名"+BRANCHES[branch][0]); runOneRow(signKey(branch), new ReadLineCallBack() { public void readLine(String line) { // TODO Auto-generated method stub if(!line.startsWith("jar 已签名"))return; System.out.println("签名成功"); progress = "签名成功!!"; } }); if(!progress.equals("签名成功!!")){//检测签名是否添加成功 progress = "error:签名失败!!!"; return; } progress = "正在进行apk对齐"; //准备进行4k对齐 runOneRow(zipAlign(branch), new ReadLineCallBack() { public void readLine(String line) { // TODO Auto-generated method stub if(line.startsWith("Unable to open")){ System.out.println("4k对齐失败"); progress = "4k对齐失败!!!"; return; } if(!line.equals("Verification succesful"))return; System.out.println("4k对齐成功"); progress = "4k对齐成功"; } }); if(!progress.equals("4k对齐成功")){//检测4k对齐是否成功 progress = "error:4k对齐失败"; return; } } progress = "正在准备传送文件"; File sourceFile = new File(BRANCHES[branch][0]+APK_PATH+"\\"+(sign?APP_PUBLISH:APP_DEBUG));//需要签名和不需要签名给出的带key的app名字不一样 System.out.println("源文件"+sourceFile.getAbsolutePath()+sourceFile.exists()); System.out.println("源文件的大小是"+sourceFile.length()); if(sourceFile.exists() && sourceFile.isFile() && sourceFile.length()>100000){ File targetFile = new File(appLocation); if(copyFile(sourceFile, targetFile))System.out.println("文件复制成功"+targetFile.length()); progress = "succeed";//在这里算彻底的成功 }else { progress = "error:失败"; } isPackaging = false; } /** * 一个简单的复制文件函数 * @param sourceFile * @param targetFile * @return */ public static boolean copyFile(File sourceFile,File targetFile){ long beginTime = System.currentTimeMillis(); if(sourceFile==null||targetFile==null)return false; System.out.println("目标地址"+targetFile.getAbsolutePath()); try { if(!targetFile.exists()){//如果目标文件不存在就新建 targetFile.createNewFile(); }else {//如果目标文件存在就删除,然后新建一个 targetFile.delete(); targetFile.createNewFile(); } } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } FileInputStream fis; FileOutputStream fos; try { fis = new FileInputStream(sourceFile); fos = new FileOutputStream(targetFile); } catch (FileNotFoundException e1) { // TODO Auto-generated catch block e1.printStackTrace(); return false; } byte[] b = new byte[1024]; int len = 0; try { while ((len = fis.read(b)) != -1) { fos.write(b, 0, len); } } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); return false; } try { fos.flush(); fis.close(); fos.close(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); return false; } long endTime = System.currentTimeMillis(); System.out.println("采用传统IO FileInputStream 读取,耗时:"+ (endTime - beginTime)); return true; } }
调用打包函数是通过html发送一个ajax请求,由servlet调用java代码控制命令行进行打包,下面给出servlet的代码。这里在一个打包请求到达后,先检测是否由用户正在打包,如果有人正在打包,就返回一个错误,如果可以打包的话,根据系统当前时间计算一个文件名,并将文件名发送给客户端,供客户端在打包完毕之后下载
import java.io.IOException; import java.io.PrintWriter; import java.text.SimpleDateFormat; import java.util.Date; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import com.imaginato.tools.PackageNow; /** * Servlet implementation class PackageOL */ public class PackageOL extends HttpServlet { private static final long serialVersionUID = 1L; /** * @see HttpServlet#HttpServlet() */ public PackageOL() { super(); // TODO Auto-generated constructor stub } /** * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse response) */ protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // TODO Auto-generated method stub System.out.println("有人访问在线打包servlet"+PackageNow.isPackaging); int branch = 0;//分支选择 int environment = 0;//环境选择 boolean needSign = false; try { branch = new Integer(request.getParameter("branch")); environment = new Integer(request.getParameter("environment")); if(request.getParameter("needSign")!=null && request.getParameter("needSign").length()>0)needSign = true; } catch (Exception e) { // TODO: handle exception e.printStackTrace(); System.out.println("没有传来分支号!!!"); return; } System.out.println("当前选择分支"+branch+" 环境"+environment+" 是否签名"+needSign); SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd"); String fileName = "AppII"+PackageNow.environmentNames[environment]+"_"+dateFormat.format(new Date(System.currentTimeMillis())).concat(".apk");//QravedIIStaging_20160508.apk String appLocation = getServletConfig().getServletContext().getRealPath("/").concat("release\\").concat(fileName);//将最终要给客户的文件的服务器路径发给打包类,让打包类打包完以后将文件复制到这个目录下 PrintWriter out = response.getWriter(); if(PackageNow.isPackaging){ System.out.println("现在正在打包"); out.write("packaging"); out.flush(); out.close(); return; }else { out.write(fileName);//将准备放过去的文件名返回 } out.flush(); out.close(); PackageNow.isPackaging = true; PackageNow.buildPackage(appLocation,branch,environment,needSign); } }
下面贴出web页面的HTML代码,里面包含了布局和ajax请求,这里发给服务器的分支号是发的序号,环境也是序号,这个序号就是上面PackageNow.java里面静态字符串数组的下标
<!DOCTYPE html> <html> <head> <title>index.html</title> <meta name="keywords" content="keyword1,keyword2,keyword3"> <meta name="description" content="this is my page"> <meta name="content-type" content="text/html"; charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script> <script src="./js/bootstrap.min.js"></script> <link rel="stylesheet" type="text/css" href="./css/bootstrap.min.css">--> </head> <body> <div class="container-fluid"> <div class="jumbotron"> <h1>欢迎使用Qraved Android HTML5 自动打包工具!</h1> <p></p> <p><a class="btn btn-primary btn-lg" href="#" role="button">Learn more</a></p> </div> <div style="margin-left: auto;margin-right: auto"> <div class="checkbox"> <label> <input id = "autoSign" type="checkbox"> 自动签名 </label> </div> <div class="dropdown"> <button id=currentBranch class="btn btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true"> 当前分支:sprint8 </button> <ul class="dropdown-menu" aria-labelledby="dropdownMenu1"> <li class=branch code=0><a href="#">master</a></li> <li class=branch code=1><a href="#">sprint7</a></li> <li class=branch code=2><a href="#">sprint8</a></li> </ul> </div> <div class="btn-group" data-toggle="buttons"> <label class="btn btn-primary active env"code="0"> <input type="radio" name="options" id="option1" autocomplete="off" checked> staging </label> <label class="btn btn-primary env"code="1"> <input type="radio" name="options" id="option2" autocomplete="off"> dev </label> <label class="btn btn-primary env" code="6"> <input type="radio" name="options" id="option4" autocomplete="off"> live </label> </div> <div> <span id="process">准备打包</span> </div> <div> <button type="button" class="btn btn-primary" id=pull>拉取最新代码</button> <button type="button" class="btn btn-success" id="startPackage">开始打包</button> <button type="button" class="btn btn-info" id="download">下载apk</button> </div> </div> </div> </body> <script type="text/javascript"> var showDialog = false;//这个是当前页面是否已经显示过一遍打板成功 var choosedBranch = "2";//这个记录当前选定的分支号,有三种选择,0,1,2,默认为2 var environment = "0"; var autoSign = false; var isPulling = false; $("#startPackage").click(function(){ console.log("点击了开始打包"); $.get("./PackageOL", "branch="+choosedBranch+"&environment="+environment+(autoSign?"&needSign=true":""), function(data, textStatus, req) { console.log("发来的data=="+data); if(data=="packaging"){ $("#process").text("正在打包中,请稍等"); window.alert("当前有其他用户正在打板,请稍候") return; }else if(data=="0"){ $("#process").text("准备就绪"); }else{ $("#process").text("开始打包"); showDialog = false; $("#download").unbind("click").click(function() { console.log("点击了下载"); window.open("./release/"+data);//正常的话,返回的data是文件名 }); } }); var task = window.setInterval(function() { console.log("发送获取进度的请求"); $.get("./PackageProcess", "", function(data, textStatus, req) { console.log("ajax返回data是"+data); if(data=="succeed"){ $("#process").text("打包成功,请点击下载"); if(showDialog==false){ window.alert("打包成功,请点击下载"); showDialog = true; } window.clearInterval(task); return; }else{ if(data.substr(0, 5)=="error"){ window.clearInterval(task); showDialog = true; window.alert("打包失败,原因"+data); } $("#process").text(data); } }); }, 2500); }); //下面是控制选择分支的下啦菜单 $(".branch").click(function() { var li = this; $("#currentBranch").text("当前分支:"+$(li).text()); choosedBranch = $(li).attr("code"); console.log("当前选定"+choosedBranch); }); $("#pull").click(function() { console.log("准备pull最新代码"+choosedBranch); if(isPulling){ window.alert("上次代码还没有pull完"); return; } $("#process").text("正在拉取代码,请稍等"); isPulling = true; $.get("./PullCodeServlet", "branch="+choosedBranch, function(data, textStatus, req) { console.log("pull代码的data是"+data); if(data=="succeed")window.alert("pull 代码成功"); else window.alert("pull 代码失败"); $("#process").text("代码拉取完毕"); isPulling = false; }); }); $(".env").click(function() { var button = this; environment = $(button).attr("code"); console.log("environment是"+environment); }); $("#autoSign").click(function() { autoSign = !autoSign; }); </script> </html>
还有拉取代码,观察进度的servlet,限于篇幅这里就先不贴了