跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Java大前端java

基于 Docker 的 Web 应用一键打包为 Android APK 方案

一种基于 Docker 容器化技术的 Web 应用自动打包为 Android APK 的方案。通过构建 Spring Boot 后端服务,结合 Docker 镜像封装 Android 构建环境,Web 团队无需掌握原生开发技能即可实现一键打包。系统支持传入 Web 地址、图标和应用名称,自动完成项目初始化、编译及签名流程。该方案降低了技术门槛,提升了交付效率,并易于集成到 CI/CD 流水线中,适用于多租户定制化需求。

ServerBase发布于 2026/4/6更新于 2026/10/25111 浏览

引言

随着公司业务的快速发展,我们计划推出一款面向移动端用户的应用。然而,当前开发团队主要由 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 流水线)只需提供以下三个参数:

  1. 目标 Web 网站的 URL(即 App 启动后加载的地址)
  2. 应用图标文件(支持 PNG 格式,用于生成 launcher icon)
  3. 应用名称(显示在手机桌面上的 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 增强功能,构建更完整的跨端发布体系。

目录

  1. 引言
  2. 现状与挑战
  3. 创新解决方案:容器化一键打包
  4. 技术实现概览
  5. 1. Dockerfile
  6. 使用官方 OpenJDK 8 作为基础镜像
  7. 设置工作目录
  8. 安装必要的工具
  9. 复制 Maven 构建的 JAR 文件
  10. 注意:Dockerfile 应放在 backend 目录,或使用相对路径
  11. 如果 Dockerfile 在 backend 目录:COPY target/web-to-app-backend-1.0.0.jar app.jar
  12. 如果 Dockerfile 在项目根目录:COPY backend/target/web-to-app-backend-1.0.0.jar app.jar
  13. 暴露端口
  14. 设置 JVM 参数(可选,根据实际情况调整)
  15. 启动应用
  16. 2. 自动化脚本
  17. ============================================
  18. Docker 镜像构建和发布脚本
  19. ============================================
  20. 配置 - 请修改为你的 Docker Hub 用户名
  21. 检查 Docker 是否安装
  22. 检查 Maven 是否安装
  23. 1. 构建 Spring Boot JAR
  24. 2. 构建 Docker 镜像
  25. 3. 标记镜像
  26. 4. 询问是否推送
  27. 3. Java 调用代码
  28. 价值与收益

更多推荐文章

查看全部
  • 基于 Docker 在 NVIDIA 5090 上部署 ComfyUI 与 Stable-Diffusion-Webui
  • Mac M3 安装 Antigravity Agent 显示已损坏问题解决方案
  • C++从零实现Json-Rpc框架:项目介绍、技术选型与环境搭建
  • 理解 RAG 真实边界:Spring AI 三种架构落地实践
  • DeepSeek-R1-Distill-Llama-8B 模型安全与对抗攻击防护
  • 从零开始创建 cli-progress 自定义预设:打造独一无二进度条样式
  • C++ 内存管理进阶:从裸指针到智能指针的实战指南
  • C++11 新特性详解:Lambda、移动语义与可变参数模板
  • HarmonyOS6 RcButton 组件样式系统深度剖析
  • 2026 春晚国产人形机器人技术突破与核心架构解析
  • 医疗连续体机器人模块化控制界面设计与 Python 库应用
  • 二级 Python 考试真题及参考代码解析(基本操作篇)
  • OpenCLAW 在 CentOS 7 环境下的安装部署
  • Visual C++ 运行库全版本一键安装工具 VisualCppRedist AIO 使用指南
  • 相交链表题解与详细分析
  • 在 Kubernetes 中部署 Kafka 高可用集群实战
  • 飞算 JavaAI:智能编程助手助力 Java 高效开发
  • 字节开源模型 DA3 使用教程:从任意视角恢复视觉空间
  • 线性表、顺序表与链表详解(C 语言版)
  • YOLOFuse 与无人机红外相机配套实战部署

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online