Android:启动本地 http-server 加载 h5 游戏

时间:2025年2月16日

地点:深圳.前海湾

需求

我们都知道 webview 可加载 URI,他有自己的协议 scheme:

  • content://  标识数据由 Content Provider 管理
  • file://     本地文件 
  • http://     网络资源

特别的,如果你想直接加载 Android 应用内 assets 内的资源你需要使用`file:///android_asset`,例如:

file:///android_asset/demo/index.html

我们本次的需求是:有一个 H5 游戏,需要 http 请求 index.html 加载、运行游戏

通常我们编写的 H5 游戏直接拖动 index.html 到浏览器打开就能正常运行游戏,当本次的游戏就是需要 http 请求才能,项目设计就是这样子啦(省略一千字)

开始

如果你有一个 index.html 的 File 对象 ,可以使用`Uri.fromFile(file)` 转换获得 Uri 可以直接加载

mWebView.loadUrl(uri.toString());

这周染上甲流,很不舒服,少废话直接上代码

  • 复制 assets 里面游戏文件到 files 目录
  • 找到 file 目录下的 index.html
  • 启动 http-server 服务
  • webview 加载 index.html
import java.io.File;

public class MainActivity extends AppCompatActivity {
    private final String TAG = "hello";

    private WebView mWebView;

    private Handler H = new Handler(Looper.getMainLooper());

    private final int LOCAL_HTTP_PORT = 8081;

    private final String SP_KEY_INDEX_PATH = "index_path";

    private LocalHttpGameServer mLocalHttpGameServer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_main);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });

        // 初始化 webview
        mWebView = findViewById(R.id.game_webview);
        initWebview();

        testLocalHttpServer();
    }

    private void testLocalHttpServer(Context context) {
        final String assetsGameFilename = "H5Game";

        copyAssetsGameFileToFiles(context, assetsGameFilename, new FindIndexCallback() {
            @Override
            public void onResult(File indexFile) {
                if (indexFile == null || !indexFile.exists()) {
                    return;
                }

                // 大概测试了下 NanoHTTPD 似乎需要在主线程启动
                H.post(new Runnable() {
                    @Override
                    public void run() {
                        // 启动 http-server
                        if (mLocalHttpGameServer == null) {
                            final String gameRootPath = indexFile.getParentFile().getAbsolutePath();
                            mLocalHttpGameServer = new LocalHttpGameServer(LOCAL_HTTP_PORT, gameRootPath);
                        }

                        // 访问本地服务 localhost 再合适不过
                        // 当然你也可以使用当前网络的 IP 地址,但是你得获取 IP 地址,指不定还有什么获取敏感数据的隐私
                        String uri = "http://localhost:" + LOCAL_HTTP_PORT + "/index.html";
                        mWebView.loadUrl(uri);
                    }
                });
            }
        });
    }

    // 把 assets 目录下的文件拷贝到应用 files 目录
    private void copyAssetsGameFileToFiles(Context context, String filename, FindIndexCallback callback) {
        if (context == null) {
            return;
        }

        String gameFilename = findGameFilename(context.getAssets(), filename);

        // 文件拷贝毕竟是耗时操作,开启一个子线程吧
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 读取拷贝到 files 目录后 index.html 文件路径的缓存
                // 防止下载再次复制文件
                String indexPath = SPUtil.getString(SP_KEY_INDEX_PATH, "");
                if (!indexPath.isEmpty() && new File(indexPath).exists()) {
                    if (callback != null) {
                        callback.onResult(new File(indexPath));
                    }
                    return;
                }

                File absGameFileDir = copyAssetsToFiles(context, gameFilename);

                // 拷贝到 files 目录后,找到第一个 index.html 文件缓存路径
                File indexHtml = findIndexHtml(absGameFileDir);
                if (indexHtml != null && indexHtml.exists()) {
                    SPUtil.setString(SP_KEY_INDEX_PATH, indexHtml.getAbsolutePath());
                }

                if (callback != null) {
                    callback.onResult(indexHtml);
                }
            }
        }).start();
    }

    public File copyAssetsToFiles(Context context, String assetFileName) {
        File filesDir = context.getFilesDir();
        File outputFile = new File(filesDir, assetFileName);

        try {
            String fileNames[] = context.getAssets().list(assetFileName);
            if (fileNames == null) {
                return null;
            }

            // lenght == 0 可以认为当前读取的是文件,否则是目录
            if (fileNames.length > 0) {
                if (!outputFile.exists()) {
                    outputFile.mkdirs();
                }
                // 目录,主要路径拼接,因为需要拷贝目录下的所有文件
                for (String fileName : fileNames) {
                    // 递归哦
                    copyAssetsToFiles(context, assetFileName + File.separator + fileName);
                }
            } else {
                // 文件
                InputStream is = context.getAssets().open(assetFileName);
                FileOutputStream fos = new FileOutputStream(outputFile);
                byte[] buffer = new byte[1024];
                int byteCount;
                while ((byteCount = is.read(buffer)) != -1) {
                    fos.write(buffer, 0, byteCount);
                }
                fos.flush();
                is.close();
                fos.close();
            }
        } catch (Exception e) {
            return null;
        }
        return outputFile;
    }

    private interface FindIndexCallback {
        void onResult(File indexFile);
    }

    public static File findIndexHtml(File directory) {
        if (directory == null || !directory.exists() || !directory.isDirectory()) {
            return null;
        }

        File[] files = directory.listFiles();
        if (files == null) {
            return null;
        }

        for (File file : files) {
            if (file.isFile() && file.getName().equals("index.html")) {
                return file;
            } else if (file.isDirectory()) {
                File index = findIndexHtml(file);
                if (index != null) {
                    return index;
                }
            }

        }

        return null;
    }

    private String findGameFilename(AssetManager assets, String filename) {
        try {
            // 这里传空字符串,读取返回 assets 目录下所有的名列表
            String[] firstFolder = assets.list("");
            if (firstFolder == null || firstFolder.length == 0) {
                return null;
            }

            for (String firstFilename : firstFolder) {
                if (firstFilename == null || firstFilename.isEmpty()) {
                    continue;
                }

                if (firstFilename.equals(filename)) {
                    return firstFilename;
                }
            }
        } catch (IOException e) {
        }

        return null;
    }

    private void initWebview() {
        mWebView.setBackgroundColor(Color.WHITE);

        WebSettings webSettings = mWebView.getSettings();
        webSettings.setJavaScriptEnabled(true);// 游戏基本都有 js
        webSettings.setDomStorageEnabled(true);
        webSettings.setAllowUniversalAccessFromFileURLs(true);
        webSettings.setAllowContentAccess(true);
        // 文件是要访问的,毕竟要加载本地资源
        webSettings.setAllowFileAccess(true);
        webSettings.setAllowFileAccessFromFileURLs(true);

        webSettings.setUseWideViewPort(true);
        webSettings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.SINGLE_COLUMN);
        webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
        webSettings.setLoadWithOverviewMode(true);
        webSettings.setDisplayZoomControls(false);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
        }
        if (Build.VERSION.SDK_INT >= 26) {
            webSettings.setSafeBrowsingEnabled(true);
        }
    }
}

差点忘了,高版本 Android 设备需要配置允许 http 明文传输,AndroidManifest 需要以下配置:

  1. 必须有网络权限 <uses-permission android:name="android.permission.INTERNET" />
  2. application 配置 ​​​​​​​​​​​
  • android:networkSecurityConfig="@xml/network_security_config
  • ​​​​​​​​​​​​​​​​​​​​​android:usesCleartextTraffic="true"

network_security_config.xml

<?xml version="1.0" encoding="UTF-8"?><network-security-config>
  <base-config cleartextTrafficPermitted="true">
    <trust-anchors>     
      <certificates src="user"/>      
      <certificates src="system"/>    
    </trust-anchors>   
  </base-config>
</network-security-config>

http-server 服务类很简单,感谢开源

今天的主角:NanoHttpd Java中的微小、易于嵌入的HTTP服务器

这里值得关注的是 gameRootPath,有了它才能正确找到本地资源所在位置

package com.example.selfdemo.http;

import android.util.Log;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

import fi.iki.elonen.NanoHTTPD;

public class LocalHttpGameServer extends NanoHTTPD {
    private String gameRootPath = "";
    private final String TAG = "hello";

    public GameHttp(int port, String gameRootPath) {
        super(port);
        this.gameRootPath = gameRootPath;
        init();
    }

    public GameHttp(String hostname, int port, String gameRootPath) {
        super(hostname, port);
        this.gameRootPath = gameRootPath;
        init();
    }


    private void init() {
        try {
            final int TIME_OUT = 1000 * 60;
            start(TIME_OUT, true);
            //start(NanoHTTPD.SOCKET_READ_TIMEOUT, true);
            Log.d(TAG, "http-server init: 启动");
        } catch (IOException e) {
            Log.d(TAG, "http-server start error = " + e);
        }
    }

    @Override
    public Response serve(IHTTPSession session) {
        String uri = session.getUri();       
        String filePath = uri;
    
        //gameRootPath 游戏工作目录至关重要
        //有了游戏工作目录,http 请求 URL 可以更简洁、更方便
        if(gameRootPath != null && gameRootPath.lenght() !=0){
            filePath = gameRootPath + uri;
        }

        File file = new File(filePath);
        
        //web 服务请求的是资源,目录没有多大意义
        if (!file.exists() || !file.isFile()) {
            return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "404 Not Found");
        }

        //读取文件并返回
        try {
            FileInputStream fis = new FileInputStream(file);
            String mimeType = NanoHTTPD.getMimeTypeForFile(uri);
            return newFixedLengthResponse(Response.Status.OK, mimeType, fis, file.length());
        } catch (IOException e) {
            return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "500 Internal Error");
        }
    }
}
<template> <view class="page-container"> <view class="header-title"> <text>欢迎来到清廉其亚</text> </view> <!-- 轮播图区域 --> <view class="banner-container"> <!-- 加载状态提示 --> <view v-if="loading" class="loading-container"> <image src="/static/loading.gif" class="loading-icon" /> <text class="loading-text">轮播图数据加载中...</text> </view> <!-- 轮播图内容 --> <swiper v-else class="banner-swiper" :autoplay="true" :interval="3000" :circular="true" :indicator-dots="true" > <swiper-item v-for="(item, index) in banners" :key="index" class="swiper-item"> <image :src="item.image" mode="aspectFill" class="banner-image" /> <view class="banner-text"> <text class="banner-title">{{ item.title }}</text> <text class="banner-description">{{ item.description }}</text> </view> </swiper-item> </swiper> </view> <!-- 图标菜单区域 - 添加内部滚动 --> <view class="icon-menu-container"> <view class="section-title"> <text>清廉服务</text> </view> <!-- 内部滚动容器 --> <scroll-view class="scroll-container" scroll-y :show-scrollbar="false" :enhanced="true" :bounces="false" > <view class="icon-grid"> <view v-for="(item, index) in iconMenus" :key="index" class="icon-item" @click="navigateTo(item)" > <image :src="item.icon" class="icon-image" /> <text class="icon-text">{{ item.text }}</text> </view> </view> </scroll-view> </view> </view> </template> <script setup> import { ref, onMounted } from 'vue'; // 轮播图数据 - 初始为空数组 const banners = ref([]); const loading = ref(true); const lastUpdated = ref(''); // 图标菜单数据 const iconMenus = ref([ { icon: '/static/icons/ServiceHall.png', text: '服务大厅', name: '服务大厅', path: '/pages/WebView/WebView' }, { icon: '/static/icons/IntegrityCulture.png', text: '廉洁文化', name: '廉洁文化', path: '/pages/IntegrityCulture/IntegrityCulture' }, { icon: '/static/icons/IntegrityStandards.png', text: '廉洁标准', name: '廉洁标准', path: '/pages/IntegrityStandards/IntegrityStandards' }, { icon: '/static/icons/ComplaintChannel.png', text: '投诉渠道', name: '投诉渠道', path: '/pages/ComplaintChannel/ComplaintChannel' }, { icon: '/static/icons/ComplainCases.png', text: '投诉案例', name: '投诉案例', path: '/pages/ComplainCases/ComplainCases' }, { icon: '/static/icons/ComplaintRecord.png', text: '投诉记录', name: '投诉记录', path: '/pages/ComplaintRecord/ComplaintRecord' }, { icon: '/static/icons/OnlinComplaint.png', text: '在线投诉', name: '在线投诉', path: '/pages/OnlinComplaint/OnlinComplaint' } ]); // 获取轮播图数据 const fetchBanners = async () => { loading.value = true; try { // 使用POST方法调用API const response = await uni.request({ url: '/api/incorruptFront/public/index', method: 'POST', timeout: 10000, header: { 'Content-Type': 'application/json' }, data: { // 添加必要的请求参数(根据API文档) // 例如: page: 1, size: 10 } }); // 处理响应数据 console.log('API响应:', response); if (response && response.length >= 2) { const res = response[1]; // uni.request 返回 [err, res] // 检查响应状态码和数据格式 if (res && res.statusCode === 200 && res.data && res.data.code === 200) { // 提取所需字段,根据提供的JSON结构 banners.value = res.data.data.map(item => ({ image: item.imageUrl, // 图片URL title: item.imageTitle, // 图片标题 description: item.imageText // 图片描述文本 })); console.log('成功获取轮播图数据:', banners.value); return; // 成功获取数据,直接返回 } else if (res && res.data && res.data.msg) { console.error('API返回错误:', res.data.msg); // 显示错误提示 uni.showToast({ title: `API错误: ${res.data.msg}`, icon: 'none', duration: 3000 }); } } // 不使用默认数据,保持空数组 banners.value = []; } catch (error) { console.error('获取轮播图数据失败:', error); // 显示错误提示 uni.showToast({ title: '网络请求失败,请检查连接', icon: 'error', duration: 2000 }); banners.value = []; } finally { loading.value = false; lastUpdated.value = new Date().toLocaleString(); } }; // 组件挂载时获取数据 onMounted(() => { fetchBanners(); }); // 导航方法 const navigateTo = (item) => { uni.navigateTo({ url: item.path, // 使用配置的路径 fail: (err) => console.error('跳转失败', err) }); }; </script> <style scoped> .page-container { display: flex; flex-direction: column; height: 100vh; /*使用视口高度 */ overflow: hidden; /* 防止全局溢出 */ padding: 20rpx; box-sizing: border-box; background: linear-gradient(135deg, #f5f7fa 0%, #e4edf9 100%); } .header-title { display: flex; justify-content: center; align-items: center; font-size: 36rpx; font-weight: bold; color: #1a3a8a; margin: 20rpx 0; position: relative; flex-shrink: 0; /* 防止标题区域被压缩 */ } .header-title::before, .header-title::after { content: ""; position: absolute; top: 50%; width: 25%; height: 2rpx; background: linear-gradient(90deg, transparent, #1a3a8a, transparent); } .header-title::before { left: 5%; } .header-title::after { right: 5%; } .banner-container { width: 100%; height: 350rpx; border-radius: 20rpx; overflow: hidden; margin-bottom: 40rpx; box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1); position: relative; flex-shrink: 0; /* 防止轮播图区域被压缩 */ } .loading-container { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; background: #f0f4f9; } .loading-icon { width: 80rpx; height: 80rpx; margin-bottom: 20rpx; } .loading-text { font-size: 28rpx; color: #1a3a8a; } .banner-swiper { width: 100%; height: 100%; } .banner-image { width: 100%; height: 100%; } .banner-text { position: absolute; bottom: 40rpx; left: 30rpx; color: #fff; text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5); } .banner-title { font-size: 36rpx; font-weight: bold; display: block; margin-bottom: 10rpx; } .banner-description { font-size: 26rpx; display: block; width: 90%; } /* 图标菜单区域 */ .icon-menu-container { background: #fff; border-radius: 20rpx; padding: 30rpx; box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.05); flex: 1; /* 占据剩余空间 */ display: flex; flex-direction: column; overflow: hidden; /* 防止溢出 */ } .section-title { font-size: 32rpx; font-weight: bold; color: #1a3a8a; margin-bottom: 30rpx; padding-left: 20rpx; border-left: 8rpx solid #1a3a8a; flex-shrink: 0; /* 防止标题被压缩 */ } /* 内部滚动容器 */ .scroll-container { flex: 1; /*占据剩余空间 */ overflow: hidden; /* 隐藏滚动条 */ } /* 隐藏滚动条 */ .scroll-container ::-webkit-scrollbar { display: none; width: 0; height: 0; color: transparent; } .icon-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20rpx; padding-bottom: 20rpx; } .icon-item { display: flex; flex-direction: column; align-items: center; padding: 20rpx 10rpx; border-radius: 12rpx; background: #f8fafd; transition: all 0.3s ease; } .icon-item:active { background: #e6f0ff; transform: scale(0.98); } .icon-image { width: 100rpx; height: 100rpx; margin-bottom: 15rpx; } .icon-text { font-size: 24rpx; color: #333; text-align: center; line-height: 1.4; } /* 响应式调整 */ @media (max-width: 480px) { .icon-grid { grid-template-columns: repeat(3, 1fr); } } </style> 还是报错:Access to XMLHttpRequest at 'http://localhost:9500/' from origin 'http://localhost:8080' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. uni-h5.es.js:20597 GET http://localhost:9500/ net::ERR_FAILED 200 (OK) 我的manifest.json代理配置如下:{ "name" : "清廉其亚", "appid" : "__UNI__772DBEF", "description" : "", "versionName" : "1.0.0", "versionCode" : "100", "transformPx" : false, /* 5+App特有相关 */ "app-plus" : { "usingComponents" : true, "nvueStyleCompiler" : "uni-app", "compilerVersion" : 3, "splashscreen" : { "alwaysShowBeforeRender" : true, "waiting" : true, "autoclose" : true, "delay" : 0 }, /* 模块配置 */ "modules" : {}, /* 应用发布信息 */ "distribute" : { /* android打包配置 */ "android" : { "permissions" : [ "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>", "<uses-permission android:name=\"android.permission.VIBRATE\"/>", "<uses-permission android:name=\"android.permission.READ_LOGS\"/>", "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>", "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>", "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.CAMERA\"/>", "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>", "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>", "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>", "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>", "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>", "<uses-feature android:name=\"android.hardware.camera\"/>", "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>" ] }, /* ios打包配置 */ "ios" : {}, /* SDK配置 */ "sdkConfigs" : {} } }, /* 快应用特有相关 */ "quickapp" : {}, /* 小程序特有相关 */ "mp-weixin" : { "appid" : "", "setting" : { "urlCheck" : false }, "usingComponents" : true }, "mp-alipay" : { "usingComponents" : true }, "mp-baidu" : { "usingComponents" : true }, "mp-toutiao" : { "usingComponents" : true }, "uniStatistics" : { "enable" : false }, "vueVersion" : "3", "fallbackLocale" : "zh-Hans", "h5" : { "title" : "清廉其亚", "router" : { "mode" : "history" }, "devServer": { "port": 8080, // 端口 "disableHostCheck": true, "proxy": { "/api": { "target": "http://172.26.26.34:8080", // 目标服务器地址 "changeOrigin": true, "secure": false, // 如果是https,需要设置为true "pathRewrite": { "^/api": "" // 重写路径,去掉请求路径中的/api } } } } } }
08-16
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值