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

【MySQL#5】 事务的概念及ACID属性和使用

【MySQL#5】 事务的概念及ACID属性和使用

📃个人主页:island1314 ⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏 💞 💞 💞 * 生活总是不会一帆风顺,前进的道路也不会永远一马平川,如何面对挫折影响人生走向 – 《人民日报》 🔥 目录 * 一、什么是事务 * 二、事务的属性及使用 * 2.1 事务的 ACID 属性 * 2.2 为什么存在事务 * 2.3 事务的版本支持 * 2.4 事务的提交方式 * 2.5 事务的常见操作方式 一、什么是事务 * 定义:由一条或者多条 sql 语句构成的 sql 集合体,这个集合体合在一起共同要完成某种任务。MySQL通过多线程实现存储工作,因此在并发访问场景中,事务确保了数据操作的一致性和可靠性。 事务还规定 不同的客户端看到的数据是不相同的 * 事务就是要做的或所做的事情,主要用于 处理操作量大,复杂度高的数据

By Ne0inhk
Flutter 组件 whitecodel_auto_link 适配鸿蒙 HarmonyOS 实战:交互式文本探针,构建信息流自动链接识别与极速预览架构

Flutter 组件 whitecodel_auto_link 适配鸿蒙 HarmonyOS 实战:交互式文本探针,构建信息流自动链接识别与极速预览架构

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net Flutter 组件 whitecodel_auto_link 适配鸿蒙 HarmonyOS 实战:交互式文本探针,构建信息流自动链接识别与极速预览架构 前言 在鸿蒙(OpenHarmony)生态迈向深度社交、企业办公及即时通讯全场景覆盖的背景下,如何将枯燥的长文本转化为具备可交互能力的“信息枢纽”,已成为提升用户操作效率的关键。在鸿蒙设备这类强调分布式协同与智慧感知的移动终端上,如果应用仅能显示纯文本,而无法识别其中的网址(URL)、邮箱(Email)或电话(Phone),用户就必须通过复杂的“长按、复制、切换应用、粘贴”链路来处理信息,这极大地割裂了鸿蒙系统的流转体验。 我们需要一种能够自动扫描文本特征、支持多维热点识别且具备高性能渲染能力的富文本处理引擎。 whitecodel_auto_link 为 Flutter 开发者引入了极其简便的长文本自动链接方案。它通过内置的高精度正则匹配矩阵,自动将文本中的特定识别域转化为可点击的高亮区域。在适配到鸿蒙

By Ne0inhk
Spring Boot 后端分层开发实战:从 MVC 到三层架构详解

Spring Boot 后端分层开发实战:从 MVC 到三层架构详解

应用分层 通过上面的练习,我们学习了 Spring MVC 简单功能的开发,但是我们也发现了一些问题。目前我们程序的代码有点 “杂乱”,然而当前只是 “一点点功能” 的开发。如果我们把整个项目功能完成呢?代码会更加的 “杂乱无章”(文件乱,代码内容乱)。 也基于此,咱们接下来学习应用分层。类似公司的组织架构:公司初创阶段,一个人身兼数职,既做财务,又做人事,还有行政。随着公司的逐渐壮大,会把岗位进行细分,划分为财务部门,人事部门,行政部门等。各个部门内部还会再进行细分。 项目开发也是类似,最开始功能简单时,我们前后端放在一起开发,随着项目功能的复杂,我们分为前端和后端不同的团队,甚至更细粒度的团队。后端开发也会根据功能再进行细分。MVC 就是其中的一种拆分方式。但是随着后端人员不再涉及前端,后端开发又有了新的分层方式。 4.1 介绍 阿里开发手册中,关于工程结构部分,定义了常见工程的应用分层结构: 那么什么是应用分层呢?应用分层是一种软件开发设计思想,

By Ne0inhk

大模型实习模拟面试面经:同花顺金融大模型算法一面深度复盘(RAG、LoRA、强化学习、Agent 架构全解析)

大模型实习模拟面试面经:同花顺金融大模型算法一面深度复盘(RAG、LoRA、强化学习、Agent 架构全解析) 关键词:大模型面试|RAG 重排序|LoRA 参数优化|GRPO 训练异常处理|Agentic RL|金融 Agent 开发|AI for SE 前言:为什么这场面试值得复盘? 2026 年,大模型技术已从“学术热点”全面转向“工业落地”,尤其在金融、医疗、法律等高价值垂直领域,智能 Agent 正成为企业核心竞争力的关键载体。作为国内领先的金融科技公司,同花顺近年来大力投入金融大模型与智能投研 Agent 的研发,其算法岗面试自然聚焦于工程实现能力 + 领域理解深度 + 技术前沿敏感度三大维度。 本文基于真实模拟面试场景,完整还原一场面向大模型算法实习生岗位的一轮技术面全过程。面试官围绕 RAG 重排序机制、LoRA

By Ne0inhk