需求背景
公司內部員工使用的iOS客戶端應用希望對內開放,不需要發布於AppStore直接能夠讓內部用戶獲取,對於Android應用來說這個問題很好解決,直接下發安裝包然后就能安裝了;但是對於蘋果生態來說,這種方式是行不通的,因為蘋果本身有一套完備的應用安裝體系,除了具備一定特性之外的應用,都必須通過在AppStore上發布然后被用戶獲取。但是蘋果依然對企業內部應用(In-House應用)有所特別對待,即可通過web方式來獲取和安裝,那么我們需要做的,就是熟悉這一套實現流程。
開發准備
本項目主要說明后台服務端實現,前期還有很多准備工作,可能涉及到的是蘋果開發者賬號、企業證書生成、企業證書簽名的ipa、應用相關的bundle-identifier等,這些事項基本都是iOS客戶端開發同學來操作的,后台項目需要用到的內容都可以找他們提供。
要點說明
iOS APP
1、必須是由$299購買的企業證書簽名過的In-House應用,$99購買的證書簽名是無效的。
2、需要提供應用或者證書相關的bundle-identifier信息,因為plist中需要使用。
plist
1、plist文件必須使用固定且完整的xml格式。
2、plist文件中的ipa文件路徑無須是https協議下的。
3、plist文件必須通過https協議訪問,而且是蘋果受信任的企業證書。
方案步驟
1、通過web后台來管理和維護iOS版本。
2、web后台提供iOS應用的上傳功能,上傳的同時生成和app配套的plist文件。
3、app文件上傳成功,web后台維護記錄成功之后,會得到safari瀏覽器訪問的路徑。
4、Safari瀏覽器訪問到獲取應用的路徑之后會打開下載頁面,點擊按鈕是通過itms-services協議訪問的plist文件。
5、訪問該文件之后,手機將會自動彈窗提示當前網站想要安裝XXX應用。
6、安裝應用完成之后,首次嘗試打開應用時,系統會提示該應用未受信任,需要前往手機「設置-通用-描述文件與設備管理」下信任該應用,信任之后將可以正常打開和使用。
功能開發
1、web后台上傳和維護app應用(展開以顯示代碼)

1 <!-- Captain&D --> 2 <!-- https://www.cnblogs.com/captainad/ --> 3 <div class="modal inmodal" id="myModal_editApp" tabindex="-1" role="dialog" aria-hidden="true"> 4 <div style="width: 1000px" class="modal-dialog"> 5 <div class="modal-content animated bounceInRight"> 6 <div class="modal-header"> 7 <button type="button" class="close" data-dismiss="modal"><span 8 aria-hidden="true">×</span><span class="sr-only">關閉</span> 9 </button> 10 <h5 class="modal-title" id="configTitle" data-lang="">增加/修改應用版本</h5> 11 <input type="hidden" id="versionId" > 12 <input type="hidden" id="appTypeId" > 13 </div> 14 <div class="modal-body"> 15 <div class="row"> 16 <div class="col-sm-6"> 17 <div class="form-group"> 18 <label>對外版本號</label> 19 <input type="text" id="versionName" class="form-control" placeholder="下載時顯示的apk名稱,無需加.apk后綴"> 20 </div> 21 <div class="form-group"> 22 <label>對內版本號</label> 23 <input type="text" id="versionCode" class="form-control"> 24 </div> 25 <div class="form-group"> 26 <label id="appfile_title">應用文件</label> 27 <div id="file-pretty"> 28 <div class="form-group"> 29 <input type="file" name="accountFile" id="appfile" class="form-control" > 30 </div> 31 </div> 32 </div> 33 <div class="form-group"> 34 <label>發布版本</label> 35 <div class="checkbox checkbox-success"> 36 <input id="checkbox2" type="checkbox"> 37 <label for="checkbox2"> 38 勾選並保存修改之后,當前版本將發布成博客原創Captain&D在線可用的最新版本 39 </label> 40 </div> 41 </div> 42 <div class="form-group"> 43 <label>是否強制升級</label> 44 <div class="checkbox checkbox-success"> 45 <input id="checkbox4" type="checkbox"> 46 <label for="checkbox4"> 47 當前版本啟用之后,用戶打開客戶端后會立即強制升級成博客原創Captain&D當前版本 48 </label> 49 </div> 50 </div> 51 </div> 52 <div class="col-sm-6"> 53 <div class="form-group"> 54 <label>升級日志</label> 55 <textarea class="form-control" id="upgradeLog" rows="12" style="resize: none"></textarea> 56 </div> 57 </div> 58 </div> 59 <div class="row"> 60 <p style="color:red;display: none" id="errMsg"> 61 </p> 62 </div> 63 </div> 64 <div class="modal-footer"> 65 <button type="button" class="btn btn-success" id="saveEdit" >保存</button> 66 <button type="button" class="btn btn-white" data-dismiss="modal" data-lang="close">關閉</button> 67 </div> 68 </div> 69 </div> 70 </div>
2、從頁面上傳附件相關處理方式(展開以顯示代碼)

1 <!-- Captain&D --> 2 <!-- https://www.cnblogs.com/captainad/ --> 3 $("#saveEdit").click(function () { 4 if(validateParam()) return; 5 6 // 先進行存在性校驗 7 var formdate = new FormData(); 8 formdate.append('id', $("#versionId").val()); 9 formdate.append('versionName', $("#versionName").val()); 10 formdate.append('versionCode', $("#versionCode").val()); 11 $('#loading-modal').modal("show"); 12 $.ajax({ 13 url: "versionmng/existsSameAppVersion", 14 type: "post", 15 data: formdate, 16 processData : false, 17 contentType : false, 18 success: function(data1){ 19 if(data1.code == 200) { 20 21 // 正式發起保存請求 22 var checked = $("#checkbox2").is(':checked'); 23 var checked1 = $("#checkbox4").is(':checked'); 24 var formdate = new FormData(); 25 var fils = $("#appfile").get(0).files[0]; 26 console.log(fils); 27 formdate.append('appFile', fils); 28 formdate.append('id', $("#versionId").val()); 29 formdate.append('appType', $("#appTypeId").val()); 30 formdate.append('versionName', $("#versionName").val()); 31 formdate.append('versionCode', $("#versionCode").val()); 32 formdate.append('upgradeLog', $("#upgradeLog").val()); 33 formdate.append('appStatus', checked ? 1 : 0); 34 formdate.append('forcedUpgrade', checked1 ? 1 : 0); 35 36 $.ajax({ 37 url: "versionmng/addAppVersion", 38 type: "post", 39 data: formdate, 40 processData : false, 41 contentType : false, 42 success: function(data){ 43 if(data.code == 200) { 44 $("#myModal_editApp").modal("hide"); 45 $("#errMsg").html(""); 46 $("#errMsg").css("display", "none"); 47 swal("Successfully", "新增/修改App應用版本信息博客原創Captain&D成功", "success"); 48 initload(pageObj); 49 }else { 50 swal("Failed", data.msg, "error"); 51 } 52 $('#loading-modal').modal("hide"); 53 } 54 }); 55 56 }else { 57 swal("Failed", data1.msg, "error"); 58 $('#loading-modal').modal("hide"); 59 } 60 } 61 }); 62 })
3、Captainad通過上傳資源到雲服務器的方法(展開以顯示代碼)

1 /** 2 * 增加應用版本 3 * Captain&D 4 * https://www.cnblogs.com/captainad/ 5 */ 6 public Result addAppVersion(HttpServletRequest request, @RequestParam(value = "appFile", required = false) MultipartFile file) { 7 8 ··· 9 10 // 文件處理 11 if(file != null && file.getSize() > 0) { 12 // 檢查文件類型 13 String filename = file.getOriginalFilename(); 14 String suffix = filename.substring(filename.lastIndexOf("."), filename.length()); 15 log.info("file format: {} {}", filename, suffix); 16 if ("1".equals(appType) && !".apk".contains(suffix) || "2".equals(appType) && !".ipa".contains(suffix)) { 17 return Result.builder() 18 .code(ResultLanguage.getResultMessage(ResultMessage.WRONG_APP_FORMAT).getCode()) 19 .msg(ResultLanguage.getResultMessage(ResultMessage.WRONG_APP_FORMAT).getMsg()).build(); 20 } 21 String appName = ""; 22 if("1".equals(appType)) { 23 appName = versionName.replace(" ", "_").replace(".apk", "").concat(".apk"); 24 }else { 25 appName = versionName.replace(" ", "_").replace(".ipa", "").concat(".ipa"); 26 } 27 28 try{ 29 Map<String, String> fileMap = fileOperationService.uploadFile(appName, "/captainad/app/", file.getInputStream()); 30 if(null != fileMap && !fileMap.isEmpty()) { 31 for(Map.Entry<String, String> set : fileMap.entrySet()) { 32 String downloadUrl = set.getKey(); 33 String appMd5 = set.getValue(); 34 requestMap.put("downloadUrl", new String[]{downloadUrl}); 35 requestMap.put("appMd5", new String[]{appMd5}); 36 } 37 }else { 38 return Result.builder() 39 .code(ResultLanguage.getResultMessage(ResultMessage.APP_UPLOAD_ERROR).getCode()) 40 .msg(ResultLanguage.getResultMessage(ResultMessage.APP_UPLOAD_ERROR).getMsg()).build(); 41 } 42 }catch (Exception e) { 43 log.error("上傳客戶端App文件存在異常。", e); 44 } 45 } 46 }
4、通過拼接字符串生成plist文件
1 /** 2 * 生成iOS應用對應的plist文件 3 * Captain&D 4 * https://www.cnblogs.com/captainad/ 5 */ 6 private String genIosPlist(CaptainadAppVersionInfo captainadAppVersionInfo){ 7 StringBuilder builder = new StringBuilder(); 8 builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); 9 builder.append("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">"); 10 builder.append("<plist version=\"1.0\">"); 11 builder.append("<dict>"); 12 builder.append(" <key>items</key>"); 13 builder.append(" <array>"); 14 builder.append(" <dict>"); 15 builder.append(" <key>assets</key>"); 16 builder.append(" <array>"); 17 builder.append(" <dict>"); 18 builder.append(" <key>kind</key>"); 19 builder.append(" <string>software-package</string>"); 20 builder.append(" <key>url</key>"); 21 builder.append(" <string>").append(captainadAppVersionInfo.getDownloadUrl()).append("</string>"); 22 builder.append(" </dict>"); 23 builder.append(" </array>"); 24 builder.append(" <key>metadata</key>"); 25 builder.append(" <dict>"); 26 builder.append(" <key>bundle-identifier</key>"); 27 builder.append(" <string>").append(getSetCacheService.getConfigValue("ios_bundle_identifier")).append("</string>"); 28 builder.append(" <key>bundle-version</key>"); 29 builder.append(" <string>").append(captainadAppVersionInfo.getVersionCode()).append("</string>"); 30 builder.append(" <key>kind</key>"); 31 builder.append(" <string>software</string>"); 32 builder.append(" <key>title</key>"); 33 builder.append(" <string>Captainad App</string>"); 34 builder.append(" </dict>"); 35 builder.append(" </dict>"); 36 builder.append(" </array>"); 37 builder.append("</dict>"); 38 builder.append("</plist>"); 39 String plistName = captainadAppVersionInfo.getVersionName().concat(".plist"); 40 try { 41 InputStream is = new ByteArrayInputStream(builder.toString().getBytes("UTF-8")); 42 Map<String, String> fileMap = fileOperationService.uploadFile(plistName, "/captainad/app/plist/", is); 43 if(null != fileMap && !fileMap.isEmpty()) { 44 for(Map.Entry<String, String> entry : fileMap.entrySet()) { 45 log.info("生成的plist的文件地址:{}", entry.getKey()); 46 return entry.getKey(); 47 } 48 } 49 } catch (Exception e) { 50 log.error("生成plist文件時出現異常。", e); 51 } 52 return null; 53 }
5、數據庫表設計(展開以顯示代碼)

1 -- Captain&D 2 -- https://www.cnblogs.com/captainad/ 3 CREATE TABLE `captainad_app_version_info` ( 4 `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵', 5 `version_name` varchar(64) DEFAULT NULL COMMENT '外部版本號', 6 `version_code` varchar(64) DEFAULT NULL COMMENT '內部版本號', 7 `upgrade_log` text COMMENT '更新日志', 8 `download_url` varchar(128) DEFAULT NULL COMMENT '版本路徑', 9 `app_md5` varchar(32) DEFAULT NULL COMMENT '文件MD5', 10 `app_status` int(11) DEFAULT NULL COMMENT '版本狀態(0-關閉,1-啟用)', 11 `release_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '發布時間', 12 `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間', 13 `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間', 14 `forced_upgrade` int(11) DEFAULT '0' COMMENT '是否強制升級(0-否,1-是)', 15 `app_type` int(11) DEFAULT NULL COMMENT '應用類型(1-Android,2-iOS)', 16 PRIMARY KEY (`id`) 17 ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='App版本管理';
6、safari通過訪問路徑之后的路由處理(展開以顯示代碼)

1 /** 2 * 進入App下載安裝頁面 3 * Captain&D 4 * https://www.cnblogs.com/captainad/ 5 */ 6 @AuthorityVerify 7 @RequestMapping("ios") 8 public String toDownloadIosAppPage(HttpServletRequest request) { 9 String version = request.getParameter("version"); 10 String httpsHost = getSetCacheService.getConfigValue("file_cloud_visit_host_https"); 11 String plistUrl = httpsHost.concat("/captainad/app/plist/").concat(version).concat(".plist"); 12 request.setAttribute("plist", plistUrl); 13 return "/appmng/ios_app"; 14 }
7、應用下載頁面的plist路由協議寫法
1 <!-- Captain&D --> 2 <!-- https://www.cnblogs.com/captainad/ --> 3 <!-- 下載安裝in-house應用關鍵代碼 --> 4 <div class="wrapper wrapper-content"> 5 <div class="row"> 6 <div class="col-sm-12"> 7 <div class="middle-box text-center animated fadeInRightBig" style="margin-top: 90%;"> 8 <!--<h3 class="font-bold">這里是頁面內容</h3>--> 9 10 <div class="install-btn"> 11 <br/><a href="itms-services://?action=download-manifest&url=${plist}" class="btn btn-success btn-lg m-t"> 12 <i class="fa fa-apple"></i> Install Tesla app for iOS</a> 13 </div> 14 </div> 15 </div> 16 </div> 17 </div>
圖片參考
1、應用列表
2、應用詳情
3、掃描安裝圖示(項目暫時無法截圖,故參考自網絡,打碼處理,侵刪)
4、信任應用(項目暫時無法截圖,故參考自網絡,打碼處理,侵刪)
遇到問題及解決思路和方法
1、Safari點擊之后出現無法連接到xxx.xx.com現象。
- 檢查下發的plist文件能否訪問。
- 詢問Https證書是否是有效的並且受信任的。
- 檢查訪問的plist文件的鏈接是否是https協議的。
- 檢查下發的plist文件xml格式是否正常,可以在線格式化下,看是否報錯。
2、能夠連接但是無法下載安裝。
- 檢查plist文件中鏈接的ipa文件是否可達。
- 檢查文件格式是否為ipa,檢查ipa文件名與plist文件名是否一致。
參考資料
1、https://www.jianshu.com/p/89d22b430330
2、https://www.cnblogs.com/star91/p/5018995.html