Web To App (web网页一键打包成android Apk文件)

引言

随着公司业务的快速发展,我们计划推出一款面向移动端用户的应用。然而,当前开发团队主要由 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 增强功能,构建更完整的跨端发布体系。

Read more

FPGA 实现 OV5640 摄像头视频图像显示

FPGA 实现 OV5640 摄像头视频图像显示

目录 一、工程介绍 二、Verilog 实现 (1)OV5640初始化         (1.1)SCCB控制器         (1.2)ov5640初始化数据表 (2)DVP数据采集 (3)RAM数据缓存 (3)VGA控制器 (4)顶层模块 三、效果演示 一、工程介绍         OV5640摄像头通过DVP接口输出视频图像数据,并通过VGA接口输出给显示器。FPGA需要完成的功能包括:OV5640初始化、DVP接口数据采集、图像数据缓存、VGA数据输出。模块设计也相应按照这四个部分进行划分。         本文为学习笔记,旨在对设计过程做简要记录,存在不足,可供学习参考。 二、Verilog 实现 (1)OV5640初始化         (1.1)SCCB控制器         ov5640摄像头初始化需要向其内部配置寄存器写入数据进行配置,实现对图像数据格式、图像大小、图像反转镜像、

2025年12 电子学会 机器人三级等级考试真题

2512 青少年等级考试机器人理论真题 单选题(20题,共80分) 第1题 下列选项中,关于传感器描述正确的是? A.将非电的物理量转化为数字信号的器件 B.将非电的物理量转化为模拟信号的器件 C.将非电的物理量转化为电信号的器件 D.将电信号转化为其他形式信号的器件 第2题 下列选项中,属于半导体材料的是? A.电阻 B.发光二极管 C.铜导线 D.纯净水 第3题 下列电路符号中,用于标识光敏电阻的是? A. B. C. D. 第4题 下列选项中,说法错误的是? A.电路搭设完毕,通电前要检查电路 B.电路搭设时,因为电阻没有极性,无需考虑方向 C.电路搭设时,需要注意LED引脚的极性 D.电路搭设时,可以带电插拔元器件

AI 编程:自动化代码生成、低代码 / 无代码开发、算法优化实践

AI 编程:自动化代码生成、低代码 / 无代码开发、算法优化实践

前言 AI 编程是人工智能技术与软件工程深度融合的产物,是未来软件开发的核心趋势之一。它并非简单的「代码补全」,而是通过大语言模型、深度学习、自动化引擎等技术,实现从需求到代码的自动化生成、低门槛可视化的低代码 / 无代码开发、已有代码 / 算法的智能优化与性能提升三大核心能力。AI 编程的本质是「解放开发者生产力」—— 让开发者从重复的 CURD、固定范式的编码、繁琐的调优工作中抽离,将精力聚焦于业务逻辑设计、架构规划、核心算法创新等高价值工作。 本文将系统性讲解 AI 编程三大核心方向,全程搭配可运行完整代码、Mermaid 标准流程图、高可用 Prompt 工程示例、数据图表、技术架构图,兼顾理论深度与落地实践,所有内容均可直接复用。 一、AI 自动化代码生成:从自然语言到可执行代码的全链路生成 1.1 核心定义与技术原理 AI 自动化代码生成,是指基于大语言模型(LLM)的代码生成能力,开发者通过「

微信机器人怎么弄的?微信群里怎么添加机器人,一篇讲清楚

很多人第一次在微信群里看到机器人,都会有类似的疑问: 这是微信自带的吗? 还是要下载什么软件? 普通人能不能自己弄一个? 拉进群之后,它为什么能自动说话? 实际上,微信机器人并不是一个“神秘功能”,而是一套已经相当成熟的使用方案。只不过,大多数教程要么写得太技术化,要么只讲结果不讲过程。 下面我们就按真实使用顺序,一步一步拆开来看。 一、先把概念说清楚:微信机器人到底是什么? 很多人理解中的“微信机器人”,是那种: 会自动回消息 能在群里发言 看起来像一个人 从使用者角度看,这个理解没错。 但从原理上来说,更准确的说法是: 微信机器人 = 一个被系统托管的微信账号 + 自动化 / AI 处理逻辑 它不是安装在你手机里的插件,也不是微信官方自带的功能,而是通过平台接入微信聊天体系的一种服务形态。 像现在比较常见的 知更 AI 微信机器人,本质上都是走这条路。 二、微信机器人怎么弄?先回答最关键的几个问题 1️⃣ 要不要下载软件? 这是被问得最多的问题。 答案是:大多数情况下不需要你单独下载客户端。