时间: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 需要以下配置:
- 必须有网络权限 <uses-permission android:name="android.permission.INTERNET" />
- 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");
}
}
}