引言
随着公司业务的快速发展,我们计划推出一款面向移动端用户的应用。然而,当前开发团队主要由 Web 前端工程师组成,缺乏原生 Android 开发经验。在完成 Web 版本的业务系统后,产品团队提出了一个关键需求:希望将现有的 Web 网站'安装'到用户的 Android 手机上,以提供类似原生 App 的使用体验。
面对这一需求,我主动承接了'将 Web 应用打包为 Android APK'的任务,并着手寻找一种对 Web 团队友好、低门槛且可自动化的实现方案。
现状与挑战
传统上,将 Web 内容封装为 Android 应用(通常称为'Web App 套壳')需要搭建完整的 Android 开发环境。这包括安装并配置 Android SDK、JDK、Gradle 构建工具、Node.js(若使用 Cordova 或 Capacitor 等框架),甚至可能涉及复杂的签名流程和构建命令。对于没有 Android 开发背景的 Web 工程师而言,这一过程不仅耗时,而且容易出错,严重拖慢交付节奏。
此外,若需频繁为不同项目或客户生成定制化 APK(例如更换图标、应用名称或入口 URL),手动操作将变得极其低效,难以满足业务快速迭代的需求。
创新解决方案:容器化一键打包
为解决上述痛点,我设计并实现了一套基于 Docker 的自动化 Web-to-APK 打包系统。核心思想是:将整个 Android 构建环境与打包逻辑封装进一个 Docker 镜像中,对外暴露极简的输入接口。
使用者(如后端服务或 CI/CD 流水线)只需提供以下三个参数:
- 目标 Web 网站的 URL(即 App 启动后加载的地址)
- 应用图标文件(支持 PNG 格式,用于生成 launcher icon)
- 应用名称(显示在手机桌面上的 App 名称)
系统即可在容器内自动完成以下操作:
- 初始化 Android 项目模板(基于 WebView)
- 注入指定的 URL 作为主页面
- 替换应用图标与名称
- 自动完成编译、签名(使用调试或预置证书)
- 输出最终的
.apk文件
整个过程无需本地安装任何 Android 相关工具,也无需了解 Gradle 或 ADB 命令,真正实现了'一次封装,随处调用'。
技术实现概览
本方案包含以下关键组成部分:
1. Dockerfile
# 使用官方 OpenJDK 8 作为基础镜像
FROM openjdk:8-jdk-alpine
# 设置工作目录
WORKDIR /app
# 安装必要的工具
RUN apk add --no-cache curl bash
# 复制 Maven 构建的 JAR 文件
# 注意:Dockerfile 应放在 backend 目录,或使用相对路径
# 如果 Dockerfile 在 backend 目录:COPY target/web-to-app-backend-1.0.0.jar app.jar
# 如果 Dockerfile 在项目根目录:COPY backend/target/web-to-app-backend-1.0.0.jar app.jar
COPY backend/target/web-to-app-backend-1.0.0.jar app.jar
# 暴露端口
EXPOSE 8081
# 设置 JVM 参数(可选,根据实际情况调整)
ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC"
# 启动应用
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
2. 自动化脚本
#!/bin/bash
# ============================================
# Docker 镜像构建和发布脚本
# ============================================
# 配置 - 请修改为你的 Docker Hub 用户名
DOCKER_USERNAME="your-username" # ⚠️ 修改这里
IMAGE_NAME="web-to-app-backend"
VERSION="1.0.0"
echo "=========================================="
echo "🚀 Web to App Backend - Docker 构建和发布"
echo "=========================================="
echo ""
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo "❌ Docker 未安装,请先安装 Docker"
exit 1
fi
# 检查 Maven 是否安装
if ! command -v mvn &> /dev/null; then
echo "❌ Maven 未安装,请先安装 Maven"
exit 1
fi
# 1. 构建 Spring Boot JAR
echo "📦 步骤 1/5: 构建 Spring Boot 应用..."
cd backend
mvn clean package -DskipTests
if [ $? -ne 0 ]; then
echo "❌ Maven 构建失败!"
exit 1
fi
echo "✅ JAR 文件构建成功"
cd ..
echo ""
# 2. 构建 Docker 镜像
echo "🐳 步骤 2/5: 构建 Docker 镜像..."
docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest .
if [ $? -ne 0 ]; then
echo "❌ Docker 构建失败!"
exit 1
fi
echo "✅ Docker 镜像构建成功"
echo ""
# 3. 标记镜像
echo "🏷️ 步骤 3/5: 标记镜像..."
docker tag ${IMAGE_NAME}:${VERSION} ${DOCKER_USERNAME}/${IMAGE_NAME}:${VERSION}
docker tag ${IMAGE_NAME}:latest ${DOCKER_USERNAME}/${IMAGE_NAME}:latest
echo "✅ 镜像标记完成"
echo ""
# 4. 询问是否推送
read -p "📤 是否推送到 Docker Hub? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# 5. 登录 Docker Hub
echo "🔐 步骤 4/5: 登录 Docker Hub..."
docker login
if [ $? -ne 0 ]; then
echo "❌ Docker 登录失败!"
exit 1
fi
echo ""
# 6. 推送镜像
echo "📤 步骤 5/5: 推送镜像到 Docker Hub..."
docker push ${DOCKER_USERNAME}/${IMAGE_NAME}:${VERSION}
docker push ${DOCKER_USERNAME}/${IMAGE_NAME}:latest
if [ $? -eq 0 ]; then
echo ""
echo "=========================================="
echo "✅ 镜像发布成功!"
echo "=========================================="
echo "📦 镜像地址:"
echo " https://hub.docker.com/r/${DOCKER_USERNAME}/${IMAGE_NAME}"
echo ""
echo "🚀 使用命令:"
echo " docker pull ${DOCKER_USERNAME}/${IMAGE_NAME}:latest"
echo " docker run -d -p 8081:8081 ${DOCKER_USERNAME}/${IMAGE_NAME}:latest"
echo "=========================================="
else
echo "❌ 镜像推送失败!"
exit 1
fi
else
echo ""
echo "=========================================="
echo "✅ 镜像构建完成(未推送)"
echo "=========================================="
echo "📦 本地镜像:"
echo " ${IMAGE_NAME}:${VERSION}"
echo " ${IMAGE_NAME}:latest"
echo ""
echo "🚀 运行命令:"
echo " docker run -d -p 8081:8081 ${IMAGE_NAME}:latest"
echo "=========================================="
fi
3. Java 调用代码
package com.ghdi.api.service;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.*;
import java.util.Base64;
/**
* Web to App 打包服务(HTTP API 模式)
* 通过 HTTP 接口与 Docker 服务器通信,无需 SSH 配置
*/
public class WebToAppService {
// Linux 服务器 HTTP API 配置
// 请根据实际情况配置服务器地址,可通过环境变量或配置文件设置
private String apiBaseUrl = System.getenv("LINUX_SERVER_URL") != null
? System.getenv("LINUX_SERVER_URL")
: "http://your-server:8080"; // 默认值,请修改为实际服务器地址
/**
* 构建 APK 并返回 base64 编码(不保存到本地文件)
*
* @param webUrl Web 地址(必需)
* @param iconPath 图标文件路径(必需,支持 png/jpg,Windows 路径)
* @param appName 应用名称(必需)
* @return base64 编码的 APK 文件内容,失败抛出异常
*/
public String buildApkAsBase64(String webUrl, String iconPath, String appName) {
return buildApkAsBase64(webUrl, iconPath, appName, null);
}
/**
* 构建 APK 并返回 base64 编码(不保存到本地文件)
*
* @param webUrl Web 地址(必需)
* @param iconPath 图标文件路径(必需,支持 png/jpg,Windows 路径)
* @param appName 应用名称(必需)
* @param appId 应用包名(可选,默认"com.webapp.app")
* @return base64 编码的 APK 文件内容,失败抛出异常
*/
public String buildApkAsBase64(String webUrl, String iconPath, String appName, String appId) {
// 参数验证
if (webUrl == null || webUrl.trim().isEmpty()) {
throw new IllegalArgumentException("Web 地址不能为空");
}
if (iconPath == null || iconPath.trim().isEmpty()) {
throw new IllegalArgumentException("图标文件路径不能为空");
}
if (appName == null || appName.trim().isEmpty()) {
throw new IllegalArgumentException("应用名称不能为空");
}
// 设置默认值
if (appId == null || appId.trim().isEmpty()) {
appId = "com.webapp.app";
}
System.out.println("==========================================");
System.out.println("\[WebToApp\] 开始打包请求(返回 base64)");
System.out.println("==========================================");
System.out.println("API 地址:" + apiBaseUrl);
System.out.println("Web 地址:" + webUrl);
System.out.println("图标路径:" + iconPath);
System.out.println("应用名称:" + appName);
System.out.println("应用 ID: " + appId);
System.out.println("");
try {
// 步骤 1:读取图标并编码为 base64
System.out.println("\[WebToApp\] \[1/3\] 读取图标文件...");
String iconBase64 = readIconAsBase64(iconPath);
System.out.println("\[WebToApp\] ✓ 图标已读取并编码为 base64 (" + (iconBase64.length() / 1024) + " KB)");
System.out.println("");
// 步骤 2:发送 HTTP 请求
System.out.println("\[WebToApp\] \[2/3\] 发送打包请求到服务器...");
BuildResponse response = sendBuildRequest(webUrl, iconBase64, appName, appId);
System.out.println("\[WebToApp\] ✓ 服务器响应:" + response.message);
System.out.println("");
if (!response.success) {
throw new RuntimeException("打包失败:" + response.message);
}
// 步骤 3:下载 APK 到内存并转换为 base64
System.out.println("\[WebToApp\] \[3/3\] 下载 APK 文件并转换为 base64...");
System.out.println("\[WebToApp\] APK 路径:" + response.apkPath);
System.out.println("\[WebToApp\] APK 大小:" + (response.apkSize / 1024 / 1024) + " MB");
System.out.println("\[WebToApp\] APK URL: " + response.apkUrl);
// 验证 apkUrl 是否存在
if (response.apkUrl == null || response.apkUrl.trim().isEmpty()) {
throw new RuntimeException("APK 下载 URL 为空,无法下载 APK 文件");
}
try {
// 下载 APK 到内存并转换为 base64
String apkBase64 = downloadApkAsBase64(response.apkUrl);
System.out.println("\[WebToApp\] ✓ APK 已下载并转换为 base64");
System.out.println("\[WebToApp\] Base64 长度:" + (apkBase64.length() / 1024) + " KB");
System.out.println("");
return apkBase64;
} catch (Exception e) {
System.err.println("\[WebToApp\] ✗ APK 下载失败:" + e.getMessage());
e.printStackTrace();
throw new RuntimeException("下载 APK 文件失败:" + e.getMessage(), e);
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("调用打包服务时发生错误:" + e.getMessage(), e);
}
}
/**
* 使用 base64 图标构建 APK 并返回 base64 编码(不依赖文件路径)
*
* @param webUrl Web 地址(必需)
* @param iconBase64 图标 base64 编码(必需,纯 base64 字符串,不包含 data:image 前缀)
* @param appName 应用名称(必需)
* @param appId 应用包名(可选,默认"com.webapp.app")
* @return base64 编码的 APK 文件内容,失败抛出异常
*/
public String buildApkAsBase64WithIconBase64(String webUrl, String iconBase64, String appName, String appId) {
// 参数验证
if (webUrl == null || webUrl.trim().isEmpty()) {
throw new IllegalArgumentException("Web 地址不能为空");
}
if (iconBase64 == null || iconBase64.trim().isEmpty()) {
throw new IllegalArgumentException("图标 base64 不能为空");
}
if (appName == null || appName.trim().isEmpty()) {
throw new IllegalArgumentException("应用名称不能为空");
}
// 设置默认值
if (appId == null || appId.trim().isEmpty()) {
appId = "com.webapp.app";
}
System.out.println("==========================================");
System.out.println("\[WebToApp\] 开始打包请求(使用 base64 图标,返回 base64)");
System.out.println("==========================================");
System.out.println("API 地址:" + apiBaseUrl);
System.out.println("Web 地址:" + webUrl);
System.out.println("应用名称:" + appName);
System.out.println("应用 ID: " + appId);
System.out.println("");
try {
// 步骤 1:直接使用传入的 iconBase64
System.out.println("\[WebToApp\] \[1/3\] 使用传入的 base64 图标...");
// 清理 base64 字符串(移除可能的前缀)
String cleanBase64 = iconBase64;
if (iconBase64.contains(",")) {
cleanBase64 = iconBase64.substring(iconBase64.indexOf(",") + 1);
}
System.out.println("\[WebToApp\] ✓ 图标已准备为 base64 (" + (cleanBase64.length() / 1024) + " KB)");
System.out.println("");
// 步骤 2:发送 HTTP 请求
System.out.println("\[WebToApp\] \[2/3\] 发送打包请求到服务器...");
BuildResponse response = sendBuildRequest(webUrl, cleanBase64, appName, appId);
System.out.println("\[WebToApp\] ✓ 服务器响应:" + response.message);
System.out.println("");
if (!response.success) {
throw new RuntimeException("打包失败:" + response.message);
}
// 步骤 3:下载 APK 到内存并转换为 base64
System.out.println("\[WebToApp\] \[3/3\] 下载 APK 文件并转换为 base64...");
System.out.println("\[WebToApp\] APK 路径:" + response.apkPath);
System.out.println("\[WebToApp\] APK 大小:" + (response.apkSize / 1024 / 1024) + " MB");
System.out.println("\[WebToApp\] APK URL: " + response.apkUrl);
// 验证 apkUrl 是否存在
if (response.apkUrl == null || response.apkUrl.trim().isEmpty()) {
throw new RuntimeException("APK 下载 URL 为空,无法下载 APK 文件");
}
try {
// 下载 APK 到内存并转换为 base64
String apkBase64 = downloadApkAsBase64(response.apkUrl);
System.out.println("\[WebToApp\] ✓ APK 已下载并转换为 base64");
System.out.println("\[WebToApp\] Base64 长度:" + (apkBase64.length() / 1024) + " KB");
System.out.println("");
return apkBase64;
} catch (Exception e) {
System.err.println("\[WebToApp\] ✗ APK 下载失败:" + e.getMessage());
e.printStackTrace();
throw new RuntimeException("下载 APK 文件失败:" + e.getMessage(), e);
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("调用打包服务时发生错误:" + e.getMessage(), e);
}
}
/**
* 构建 APK 并下载到 Windows 指定目录(保留原有方法以保持向后兼容)
*
* @param webUrl Web 地址(必需)
* @param iconPath 图标文件路径(必需,支持 png/jpg,Windows 路径)
* @param appName 应用名称(必需)
* @param windowsOutputDir Windows 输出目录(必需,APK 将下载到这里)
* @return APK 文件在 Windows 上的路径,失败抛出异常
*/
public String buildApk(String webUrl, String iconPath, String appName, String windowsOutputDir) {
return buildApk(webUrl, iconPath, appName, null, windowsOutputDir);
}
/**
* 构建 APK 并下载到 Windows 指定目录(保留原有方法以保持向后兼容)
*
* @param webUrl Web 地址(必需)
* @param iconPath 图标文件路径(必需,支持 png/jpg,Windows 路径)
* @param appName 应用名称(必需)
* @param appId 应用包名(可选,默认"com.webapp.app")
* @param windowsOutputDir Windows 输出目录(必需,APK 将下载到这里)
* @return APK 文件在 Windows 上的路径,失败抛出异常
*/
public String buildApk(String webUrl, String iconPath, String appName, String appId, String windowsOutputDir) {
// 参数验证
if (webUrl == null || webUrl.trim().isEmpty()) {
throw new IllegalArgumentException("Web 地址不能为空");
}
if (iconPath == null || iconPath.trim().isEmpty()) {
throw new IllegalArgumentException("图标文件路径不能为空");
}
if (appName == null || appName.trim().isEmpty()) {
throw new IllegalArgumentException("应用名称不能为空");
}
if (windowsOutputDir == null || windowsOutputDir.trim().isEmpty()) {
throw new IllegalArgumentException("Windows 输出目录不能为空");
}
// 设置默认值
if (appId == null || appId.trim().isEmpty()) {
appId = "com.webapp.app";
}
System.out.println("==========================================");
System.out.println("\[WebToApp\] 开始打包请求");
System.out.println("==========================================");
System.out.println("API 地址:" + apiBaseUrl);
System.out.println("Web 地址:" + webUrl);
System.out.println("图标路径:" + iconPath);
System.out.println("应用名称:" + appName);
System.out.println("应用 ID: " + appId);
System.out.println("Windows 输出目录:" + windowsOutputDir);
System.out.println("");
try {
// 步骤 1:读取图标并编码为 base64
System.out.println("\[WebToApp\] \[1/4\] 读取图标文件...");
String iconBase64 = readIconAsBase64(iconPath);
System.out.println("\[WebToApp\] ✓ 图标已读取并编码为 base64 (" + (iconBase64.length() / 1024) + " KB)");
System.out.println("");
// 步骤 2:发送 HTTP 请求
System.out.println("\[WebToApp\] \[2/4\] 发送打包请求到服务器...");
BuildResponse response = sendBuildRequest(webUrl, iconBase64, appName, appId);
System.out.println("\[WebToApp\] ✓ 服务器响应:" + response.message);
System.out.println("");
if (!response.success) {
throw new RuntimeException("打包失败:" + response.message);
}
// 步骤 3:下载 APK
System.out.println("\[WebToApp\] \[3/4\] 下载 APK 文件...");
System.out.println("\[WebToApp\] APK 路径:" + response.apkPath);
System.out.println("\[WebToApp\] APK 大小:" + (response.apkSize / 1024 / 1024) + " MB");
System.out.println("\[WebToApp\] APK URL: " + response.apkUrl);
// 验证 apkUrl 是否存在
if (response.apkUrl == null || response.apkUrl.trim().isEmpty()) {
throw new RuntimeException("APK 下载 URL 为空,无法下载 APK 文件");
}
System.out.println("\[WebToApp\] Windows 输出目录:" + windowsOutputDir);
try {
String windowsApkPath = downloadApkFromUrl(response.apkUrl, windowsOutputDir, appName);
// 验证文件是否真的存在
Path apkFilePath = Paths.get(windowsApkPath);
if (!Files.exists(apkFilePath)) {
throw new RuntimeException("APK 文件下载后不存在:" + windowsApkPath);
}
long fileSize = Files.size(apkFilePath);
System.out.println("\[WebToApp\] ✓ APK 已下载到:" + windowsApkPath);
System.out.println("\[WebToApp\] 文件大小:" + (fileSize / 1024 / 1024) + " MB");
System.out.println("");
return windowsApkPath;
} catch (Exception e) {
System.err.println("\[WebToApp\] ✗ APK 下载失败:" + e.getMessage());
e.printStackTrace();
throw new RuntimeException("下载 APK 文件失败:" + e.getMessage(), e);
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("调用打包服务时发生错误:" + e.getMessage(), e);
}
}
/**
* 从 URL 下载 APK 文件到内存并转换为 base64 编码
*
* @param apkUrl APK 文件的 URL
* @return base64 编码的 APK 文件内容
* @throws Exception 下载或转换过程中的异常
*/
private String downloadApkAsBase64(String apkUrl) throws Exception {
System.out.println("\[WebToApp\] \[下载\] 开始下载 APK 到内存...");
System.out.println("\[WebToApp\] \[下载\] URL: " + apkUrl);
// 构建完整 URL
String fullUrl = apkUrl;
if (!apkUrl.startsWith("http")) {
fullUrl = apiBaseUrl + (apkUrl.startsWith("/") ? apkUrl : "/" + apkUrl);
System.out.println("\[WebToApp\] \[下载\] 完整 URL: " + fullUrl);
}
URL url = new URL(fullUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try {
connection.setRequestMethod("GET");
connection.setConnectTimeout(30000);
connection.setReadTimeout(300000); // 5 分钟下载超时
System.out.println("\[WebToApp\] \[下载\] 连接服务器...");
int responseCode = connection.getResponseCode();
System.out.println("\[WebToApp\] \[下载\] HTTP 响应代码:" + responseCode);
if (responseCode != 200) {
String errorMessage = "下载 APK 失败 (HTTP " + responseCode + ")";
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getErrorStream(), "utf-8"))) {
StringBuilder errorResponse = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
errorResponse.append(line);
}
if (errorResponse.length() > 0) {
errorMessage += ": " + errorResponse.toString();
}
}
throw new RuntimeException(errorMessage);
}
// 下载文件到内存(使用 ByteArrayOutputStream)
System.out.println("\[WebToApp\] \[下载\] 开始下载文件内容到内存...");
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try (InputStream inputStream = connection.getInputStream()) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalBytes = 0;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
totalBytes += bytesRead;
// 每下载 1MB 显示一次进度
if (totalBytes % (1024 * 1024) == 0) {
System.out.println("\[WebToApp\] \[下载\] 已下载:" + (totalBytes / 1024 / 1024) + " MB");
}
}
System.out.println("\[WebToApp\] \[下载\] 下载完成,总大小:" + (totalBytes / 1024 / 1024) + " MB");
}
// 将字节数组转换为 base64 编码
System.out.println("\[WebToApp\] \[转换\] 开始转换为 base64 编码...");
byte[] apkBytes = outputStream.toByteArray();
String base64 = Base64.getEncoder().encodeToString(apkBytes);
System.out.println("\[WebToApp\] \[转换\] Base64 编码完成");
System.out.println("\[WebToApp\] \[转换\] 原始大小:" + (apkBytes.length / 1024 / 1024) + " MB");
System.out.println("\[WebToApp\] \[转换\] Base64 大小:" + (base64.length() / 1024) + " KB");
return base64;
} catch (Exception e) {
System.err.println("\[WebToApp\] \[下载\] 下载失败:" + e.getClass().getSimpleName() + " - " + e.getMessage());
e.printStackTrace();
throw e;
} finally {
connection.disconnect();
}
}
/**
* 发送打包请求到 HTTP API
*/
private BuildResponse sendBuildRequest(String webUrl, String iconBase64, String appName, String appId) throws Exception {
URL url = new URL(apiBaseUrl + "/api/build");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try {
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
connection.setDoOutput(true);
connection.setConnectTimeout(30000); // 30 秒连接超时
connection.setReadTimeout(1800000); // 30 分钟读取超时
// 构建请求 JSON
String requestJson = createRequestJson(webUrl, iconBase64, appName, appId);
// 发送请求
try (OutputStream os = connection.getOutputStream()) {
byte[] input = requestJson.getBytes("utf-8");
os.write(input, 0, input.length);
}
// 读取响应
int responseCode = connection.getResponseCode();
InputStream inputStream = (responseCode == 200)
? connection.getInputStream()
: connection.getErrorStream();
StringBuilder response = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, "utf-8"))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
String responseBody = response.toString();
if (responseCode != 200) {
throw new RuntimeException("HTTP 请求失败 (code: " + responseCode + "): " + responseBody);
}
// 解析响应
BuildResponse buildResponse = parseBuildResponse(responseBody);
// 调试:打印解析结果
System.out.println("\[WebToApp\] \[调试\] 解析结果 - success: " + buildResponse.success + ", message: " + buildResponse.message);
return buildResponse;
} finally {
connection.disconnect();
}
}
/**
* 从 URL 下载 APK 文件(保留原有方法)
*/
private String downloadApkFromUrl(String apkUrl, String windowsOutputDir, String appName) throws Exception {
System.out.println("\[WebToApp\] \[下载\] 开始下载 APK...");
System.out.println("\[WebToApp\] \[下载\] URL: " + apkUrl);
// 确保输出目录存在
Path outputDir = Paths.get(windowsOutputDir);
Files.createDirectories(outputDir);
System.out.println("\[WebToApp\] \[下载\] 输出目录:" + outputDir.toAbsolutePath());
// 构建完整 URL
String fullUrl = apkUrl;
if (!apkUrl.startsWith("http")) {
fullUrl = apiBaseUrl + (apkUrl.startsWith("/") ? apkUrl : "/" + apkUrl);
System.out.println("\[WebToApp\] \[下载\] 完整 URL: " + fullUrl);
}
URL url = new URL(fullUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try {
connection.setRequestMethod("GET");
connection.setConnectTimeout(30000);
connection.setReadTimeout(300000); // 5 分钟下载超时
System.out.println("\[WebToApp\] \[下载\] 连接服务器...");
int responseCode = connection.getResponseCode();
System.out.println("\[WebToApp\] \[下载\] HTTP 响应代码:" + responseCode);
if (responseCode != 200) {
String errorMessage = "下载 APK 失败 (HTTP " + responseCode + ")";
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getErrorStream(), "utf-8"))) {
StringBuilder errorResponse = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
errorResponse.append(line);
}
if (errorResponse.length() > 0) {
errorMessage += ": " + errorResponse.toString();
}
}
throw new RuntimeException(errorMessage);
}
// 从 URL 或 Content-Disposition 获取文件名
String fileName = getFileNameFromUrl(connection, fullUrl, appName);
System.out.println("\[WebToApp\] \[下载\] 文件名:" + fileName);
Path apkFile = outputDir.resolve(fileName);
System.out.println("\[WebToApp\] \[下载\] 保存路径:" + apkFile.toAbsolutePath());
// 下载文件
System.out.println("\[WebToApp\] \[下载\] 开始下载文件内容...");
try (InputStream inputStream = connection.getInputStream();
FileOutputStream outputStream = new FileOutputStream(apkFile.toFile())) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalBytes = 0;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
totalBytes += bytesRead;
// 每下载 1MB 显示一次进度
if (totalBytes % (1024 * 1024) == 0) {
System.out.println("\[WebToApp\] \[下载\] 已下载:" + (totalBytes / 1024 / 1024) + " MB");
}
}
System.out.println("\[WebToApp\] \[下载\] 下载完成,总大小:" + (totalBytes / 1024 / 1024) + " MB");
}
// 验证文件是否真的写入成功
if (!Files.exists(apkFile)) {
throw new RuntimeException("文件下载后不存在:" + apkFile.toAbsolutePath());
}
long fileSize = Files.size(apkFile);
System.out.println("\[WebToApp\] \[下载\] 文件已保存,大小:" + (fileSize / 1024 / 1024) + " MB");
return apkFile.toAbsolutePath().toString();
} catch (Exception e) {
System.err.println("\[WebToApp\] \[下载\] 下载失败:" + e.getClass().getSimpleName() + " - " + e.getMessage());
e.printStackTrace();
throw e;
} finally {
connection.disconnect();
}
}
/**
* 从响应头或 URL 获取文件名
*/
private String getFileNameFromUrl(HttpURLConnection connection, String url, String appName) {
// 尝试从 Content-Disposition 获取
String contentDisposition = connection.getHeaderField("Content-Disposition");
if (contentDisposition != null && contentDisposition.contains("filename=")) {
String fileName = contentDisposition.substring(contentDisposition.indexOf("filename=") + 9);
fileName = fileName.replace("\"", "").trim();
if (fileName.endsWith(".apk")) {
return fileName;
}
}
// 从 URL 获取
String urlFileName = url.substring(url.lastIndexOf('/') + 1);
if (urlFileName.endsWith(".apk")) {
return urlFileName;
}
// 使用应用名称
return appName.replaceAll("[^a-zA-Z0-9]", "_") + ".apk";
}
/**
* 解析构建响应 JSON
*/
private BuildResponse parseBuildResponse(String jsonResponse) {
BuildResponse response = new BuildResponse();
try {
// 调试:打印原始 JSON 响应
System.out.println("\[WebToApp\] \[调试\] 原始 JSON: " + jsonResponse);
// 更准确地检查 success 字段(支持多种格式)
// 使用正则表达式匹配 "success": true 或 "success":true
boolean isSuccess = jsonResponse.matches("(?s).*\\"success\\"\\s*:\\s*true\\b.*");
if (!isSuccess) {
// 如果正则匹配失败,尝试简单的字符串匹配
isSuccess = jsonResponse.contains("\\"success\\":true") ||
jsonResponse.contains("\\"success\\": true") ||
jsonResponse.contains("'success':true") ||
jsonResponse.contains("'success': true");
}
// 先提取 message(无论成功还是失败都需要)
response.message = extractJsonValue(jsonResponse, "message");
if (isSuccess) {
response.success = true;
response.apkPath = extractJsonValue(jsonResponse, "apkPath");
response.apkUrl = extractJsonValue(jsonResponse, "apkUrl");
// apkSize 是数字类型,需要特殊处理
String apkSizeStr = extractJsonNumberValue(jsonResponse, "apkSize");
if (apkSizeStr != null && !apkSizeStr.isEmpty()) {
try {
response.apkSize = Long.parseLong(apkSizeStr);
} catch (NumberFormatException e) {
response.apkSize = 0;
}
}
} else {
response.success = false;
// 如果 message 为空,尝试从响应中提取更多信息
if (response.message == null || response.message.isEmpty()) {
response.message = "服务器返回失败响应(未检测到 success:true)";
}
}
} catch (Exception e) {
response.success = false;
response.message = "解析响应失败:" + e.getMessage();
// 打印原始响应的一部分用于调试
System.out.println("\[WebToApp\] \[错误\] 解析异常:" + e.getClass().getSimpleName() + " - " + e.getMessage());
System.out.println("\[WebToApp\] \[错误\] 原始响应前 200 字符:" + jsonResponse.substring(0, Math.min(200, jsonResponse.length())));
}
return response;
}
/**
* 读取图标文件并转换为 base64
* 支持文件路径或 base64 字符串作为输入
*/
private String readIconAsBase64(String iconInput) {
// Check if the input is already a base64 string (e.g., starts with "iVBORw0KGgo" for PNG)
if (iconInput.length() > 50 && (iconInput.startsWith("iVBORw0KGgo") || iconInput.startsWith("/9j/"))) {
// Common PNG/JPG base64 prefixes
System.out.println("\[WebToApp\] \[图标\] 输入被识别为 base64 字符串,直接使用。");
return iconInput;
}
// Otherwise, treat as file path
try {
Path iconFile = Paths.get(iconInput);
// 验证文件是否存在
if (!Files.exists(iconFile)) {
throw new IllegalArgumentException("图标文件不存在:" + iconInput);
}
// 验证文件格式
String iconFileName = iconFile.getFileName().toString().toLowerCase();
if (!iconFileName.endsWith(".png") && !iconFileName.endsWith(".jpg")
&& !iconFileName.endsWith(".jpeg")) {
throw new IllegalArgumentException("图标文件必须是 png 或 jpg 格式");
}
// 读取文件并转换为 base64
byte[] iconBytes = Files.readAllBytes(iconFile);
String base64 = Base64.getEncoder().encodeToString(iconBytes);
return base64;
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("读取图标文件失败:" + e.getMessage(), e);
}
}
/**
* 创建请求 JSON
*/
private String createRequestJson(String webUrl, String iconBase64, String appName, String appId) {
StringBuilder json = new StringBuilder();
json.append("{");
json.append("\\"webUrl\\":\\"").append(escapeJson(webUrl)).append("\\",");
json.append("\\"iconBase64\\":\\"").append(iconBase64).append("\\",");
json.append("\\"appName\\":\\"").append(escapeJson(appName)).append("\\",");
json.append("\\"appId\\":\\"").append(escapeJson(appId)).append("\\"");
json.append("}");
return json.toString();
}
/**
* 转义 JSON 字符串
*/
private String escapeJson(String str) {
return str.replace("\\\\", "\\\\\\\\")
.replace("\"", "\\\\\"")
.replace("\\n", "\\\\n")
.replace("\\r", "\\\\r")
.replace("\\t", "\\\\t");
}
/**
* 从 JSON 中提取字符串值
*/
private String extractJsonValue(String json, String key) {
String searchKey = "\\"" + key + "\\"";
int keyIndex = json.indexOf(searchKey);
if (keyIndex == -1) {
return null;
}
int colonIndex = json.indexOf(":", keyIndex);
if (colonIndex == -1) {
return null;
}
int startIndex = json.indexOf("\"", colonIndex) + 1;
if (startIndex == 0) {
return null;
}
int endIndex = json.indexOf("\"", startIndex);
if (endIndex == -1) {
return null;
}
return json.substring(startIndex, endIndex).replace("\\\\\"", "\\"");
}
/**
* 从 JSON 中提取数字值
*/
private String extractJsonNumberValue(String json, String key) {
String searchKey = "\\"" + key + "\\"";
int keyIndex = json.indexOf(searchKey);
if (keyIndex == -1) {
return null;
}
int colonIndex = json.indexOf(":", keyIndex);
if (colonIndex == -1) {
return null;
}
// 跳过空格
int valueStart = colonIndex + 1;
while (valueStart < json.length() && Character.isWhitespace(json.charAt(valueStart))) {
valueStart++;
}
// 查找数字的结束位置(逗号、}或]
int valueEnd = valueStart;
while (valueEnd < json.length()) {
char c = json.charAt(valueEnd);
if (c == ',' || c == '}' || c == '\]' || Character.isWhitespace(c)) {
break;
}
valueEnd++;
}
if (valueEnd <= valueStart) {
return null;
}
return json.substring(valueStart, valueEnd).trim();
}
/**
* 构建响应对象
*/
private static class BuildResponse {
boolean success;
String message;
String apkPath;
String apkUrl;
long apkSize;
}
// Getter 和 Setter 方法
public void setApiBaseUrl(String apiBaseUrl) {
this.apiBaseUrl = apiBaseUrl;
}
public String getApiBaseUrl() {
return apiBaseUrl;
}
}
价值与收益
- 降低技术门槛:Web 团队无需学习 Android 开发即可产出可用的移动应用。
- 提升交付效率:从'小时级'手动配置缩短至'分钟级'自动化生成。
- 支持多租户定制:轻松为不同客户或业务线生成专属品牌 App。
- 便于集成 CI/CD:可无缝接入 Jenkins、GitLab CI 等流水线,实现持续交付。
该方案已在内部多个项目中成功落地,稳定输出可安装的 Android 应用,有效支撑了公司移动端业务的快速拓展。未来还可进一步扩展支持 iOS(通过类似方案生成 .ipa)或 PWA 增强功能,构建更完整的跨端发布体系。
