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");
        }
    }
}
相关推荐
每次的天空4 分钟前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭32 分钟前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日1 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安2 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑2 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟6 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡7 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi007 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体
zhangphil9 小时前
Android理解onTrimMemory中ComponentCallbacks2的内存警戒水位线值
android
你过来啊你9 小时前
Android View的绘制原理详解
android