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");
        }
    }
}
--------- beginning of system --------- beginning of main ---------------------------- PROCESS STARTED (3330) for package org.cocos2d.demo ---------------------------- 2025-07-15 15:48:35.316 3330-3359 eglCodecCommon org.cocos2d.demo E glUtilsParamSize: unknow param 0x000082da 2025-07-15 15:48:35.316 3330-3359 eglCodecCommon org.cocos2d.demo E glUtilsParamSize: unknow param 0x000082e5 2025-07-15 15:48:35.328 3330-3359 eglCodecCommon org.cocos2d.demo E glUtilsParamSize: unknow param 0x00008c29 2025-07-15 15:48:35.328 3330-3359 eglCodecCommon org.cocos2d.demo E glUtilsParamSize: unknow param 0x000087fe 2025-07-15 15:48:35.363 3330-3359 EGL_emulation org.cocos2d.demo E tid 3359: eglSurfaceAttrib(1493): error 0x3009 (EGL_BAD_MATCH) 2025-07-15 15:48:36.976 3330-3357 jswrapper org.cocos2d.demo E ScriptEngine::onGetStringFromFile stream not found, possible missing file. 2025-07-15 15:48:36.976 3330-3357 jswrapper org.cocos2d.demo E ScriptEngine::runScript script stream, buffer is empty! 2025-07-15 15:48:36.976 3330-3357 jswrapper org.cocos2d.demo E [ERROR] Failed to invoke require, location: C:/ProgramData/cocos/editors/Creator/2.4.13/resources/cocos2d-x/cocos/scripting/js-bindings/manual/jsb_global.cpp:299 2025-07-15 15:48:37.026 3330-3357 jswrapper org.cocos2d.demo E ScriptEngine::evalString catch exception: 2025-07-15 15:48:37.050 3330-3357 jswrapper org.cocos2d.demo E ERROR: Uncaught ReferenceError: self is not defined, location: src/assets/_plugs/lib/gravityengine.mg.cocoscreator.min.dbb97.js:0:0 STACK: [0]anonymous@src/assets/_plugs/lib/gravityengine.mg.cocoscreator.min.dbb97.js:2 [1]anonymous@src/assets/_plugs/lib/gravityengine.mg.cocoscreator.min.dbb97.js:3 [2]anonymous@jsb-adapter/jsb-engine.js:2975 [3]download@jsb-adapter/jsb-engine.js:2984 [4]downloadScript@jsb-adapter/jsb-engine.js:2971 [5]a@src/cocos2d-jsb.28d62.js:16668 [6]anonymous@src/cocos2d-jsb.28d62.js:16678 [7]retry@src/cocos2d-jsb.28d62.js:18111 [8]download@src/cocos2d-jsb.28d62.js:16663 [9]load@src/cocos2d-jsb.28d62.js:17318 [10]94.e.exports@src/cocos2d-jsb.28d62.js:17134 [11]_flow@src/cocos2d-jsb.28d62.js:17579 [12]async@src/cocos2d-jsb.28d62.js:17574 [13]anonymous@src/cocos2d-jsb.28d62.js:17261 [14]forEach@src/cocos2d-jsb.28d62.js:18189 [15]94.e.exports@src/cocos2d-jsb.28d62.js:17244 [16]_flow@src/cocos2d-jsb.28d62.js:17579 [17]anonymous@src/cocos2d-jsb.28d62.js:17586 [18]98.e.exports@src/cocos2d-jsb.2 2025-07-15 15:48:37.052 3330-3357 jswrapper org.cocos2d.demo E ScriptEngine::evalString script src/assets/_plugs/lib/gravityengine.mg.cocoscreator.min.dbb97.js, failed! 2025-07-15 15:48:37.053 3330-3357 jswrapper org.cocos2d.demo E [ERROR] Failed to invoke require, location: C:/ProgramData/cocos/editors/Creator/2.4.13/resources/cocos2d-x/cocos/scripting/js-bindings/manual/jsb_global.cpp:299 2025-07-15 15:48:49.228 1715-2033 bt_btif com.android.bluetooth E register_notification_rsp: Avrcp device is not connected, handle: 0x0 2025-07-15 15:48:49.228 1715-2033 bt_btif com.android.bluetooth E register_notification_rsp: Avrcp device is not connected, handle: 0x0 2025-07-15 15:48:49.241 1452-1763 OMXNodeInstance media.codec E setConfig(0xf5210060:google.mp3.decoder, ConfigPriority(0x6f800002)) ERROR: Undefined(0x80001001) 2025-07-15 15:48:49.241 1452-1763 OMXNodeInstance media.codec E getConfig(0xf5210060:google.mp3.decoder, ConfigAndroidVendorExtension(0x6f100004)) ERROR: Undefined(0x80001001) 2025-07-15 15:49:00.001 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.015 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.024 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.037 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.045 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.054 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.061 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.071 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.081 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.091 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.107 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.115 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.123 1573-1586 memtrack system_server E Couldn't load memtrack module 2025-07-15 15:49:00.132 1573-1586 memtrack system_server E Couldn't load memtrack module
最新发布
07-16
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值