Android Http-server 本地 web 服务

时间: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 可以直接加载

java 复制代码
mWebView.loadUrl(uri.toString());

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

  • 复制 assets 里面游戏文件到 files 目录
  • 找到 file 目录下的 index.html
  • 启动 http-server 服务
  • webview 加载 index.html
java 复制代码
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 复制代码
<?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,有了它才能正确找到本地资源所在位置

java 复制代码
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");
        }
    }
}
相关推荐
2501_916008891 小时前
Web 前端开发常用工具推荐与团队实践分享
android·前端·ios·小程序·uni-app·iphone·webview
我科绝伦(Huanhuan Zhou)2 小时前
MySQL一键升级脚本(5.7-8.0)
android·mysql·adb
怪兽20143 小时前
Android View, SurfaceView, GLSurfaceView 的区别
android·面试
龚礼鹏3 小时前
android 图像显示框架二——流程分析
android
消失的旧时光-19433 小时前
kmp需要技能
android·设计模式·kotlin
帅得不敢出门4 小时前
Linux服务器编译android报no space left on device导致失败的定位解决
android·linux·服务器
雨白5 小时前
协程间的通信管道 —— Kotlin Channel 详解
android·kotlin
TimeFine7 小时前
kotlin协程 容易被忽视的CompletableDeferred
android
czhc11400756638 小时前
Linux1023 mysql 修改密码等
android·mysql·adb
GOATLong9 小时前
MySQL内置函数
android·数据库·c++·vscode·mysql