一圖勝千言
上一篇有
<!-- 讀寫外部存儲 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"android:maxSdkVersion="28"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"android:maxSdkVersion="28"/><!-- Android 10+ 用 MediaStore/SAF,無需額外權限 -->
- 運行時權限(Activity/Fragment)
private static final int REQ_CODE = 100;private void checkPermissionAndUnzip() {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)!= PackageManager.PERMISSION_GRANTED) {requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},REQ_CODE);return;}}unzipAssets();
}@Override
public void onRequestPermissionsResult(int requestCode,@NonNull String[] permissions,@NonNull int[] grantResults) {super.onRequestPermissionsResult(requestCode, permissions, grantResults);if (requestCode == REQ_CODE && grantResults.length > 0&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {unzipAssets();} else {Toast.makeText(this, "需要存儲權限", Toast.LENGTH_SHORT).show();}
}
- 解壓工具類
public class ZipUtils {public static void unzipAsset(Context ctx, String assetName,File destDir) throws IOException {if (!destDir.exists()) destDir.mkdirs();try (InputStream in = ctx.getAssets().open(assetName);ZipInputStream zin = new ZipInputStream(in)) {ZipEntry entry;byte[] buffer = new byte[4096];while ((entry = zin.getNextEntry()) != null) {File file = new File(destDir, entry.getName());if (entry.isDirectory()) {file.mkdirs();} else {// 確保父目錄存在File parent = file.getParentFile();if (!parent.exists()) parent.mkdirs();try (FileOutputStream out = new FileOutputStream(file)) {int len;while ((len = zin.read(buffer)) != -1) {out.write(buffer, 0, len);}}}zin.closeEntry();}}}
}
- 調用解壓
private void unzipAssets() {new Thread(() -> {try {// 目標目錄:/storage/emulated/0/Android/<package>/web/distFile destDir = new File(Environment.getExternalStorageDirectory(),"Android/" + getPackageName() + "/web/dist");ZipUtils.unzipAsset(this, "dist.zip", destDir);runOnUiThread(() ->Toast.makeText(this, "解壓完成", Toast.LENGTH_SHORT).show());} catch (IOException e) {e.printStackTrace();runOnUiThread(() ->Toast.makeText(this, "解壓失敗:" + e.getMessage(),Toast.LENGTH_SHORT).show());}}).start();
}
- 使用示例
checkPermissionAndUnzip();
使用解壓結果
File webDir = new File(getFilesDir(), "web");
File indexHtml = new File(webDir, "index.html");
其他
net:ERR_ACCESS_DENIED
net::ERR_ACCESS_DENIED
并不是網絡錯誤,而是 WebView 拒絕訪問本地文件 的通用提示。
99% 的場景只踩了下面 3 個坑 之一,按清單逐條檢查即可解決。
? 1. 文件不在「允許路徑」里(最常見)
場景 | 是否允許 |
---|---|
/data/data/<包>/files/xxx | ? 私有目錄,允許 |
/storage/emulated/0/xxx | ? 需 可讀權限 + file:// 白名單 |
assets/ 或 res/raw/ | ? 需 file:///android_asset/ 協議 |
正確打開姿勢
// 私有目錄 files/web/index.html
webView.loadUrl("file:///data/data/" + getPackageName() + "/files/web/index.html");// assets 目錄
webView.loadUrl("file:///android_asset/web/index.html");
? 2. 忘記開 JavaScript 或 文件訪問
WebSettings ws = webView.getSettings();
ws.setJavaScriptEnabled(true); // 必須
ws.setAllowFileAccess(true); // 必須
ws.setAllowContentAccess(true); // 建議
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {ws.setAllowFileAccessFromFileURLs(true); // assets 里調 JS 需要ws.setAllowUniversalAccessFromFileURLs(true);
}
? 3. Android 10+ 分區存儲 限制
- targetSdkVersion ≥ 29 且文件在 外部存儲 時,
即使申請了READ_EXTERNAL_STORAGE
也打不開。
快速解決(開發階段)
<applicationandroid:requestLegacyExternalStorage="true"... />
正式上架 請把文件放到:
getFilesDir()
/getCacheDir()
- 或
assets/
- 或使用 FileProvider 生成
content://
URI
? 4. 用了 FileProvider 卻給錯路徑(少見)
若用 FileProvider.getUriForFile()
生成 content://
地址,必須:
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
WebView 不支持 content://
直接 loadUrl()
,請轉回 file://
或拷貝到私有目錄。
🎯 一鍵排查清單
檢查項 | 正確示例 |
---|---|
文件存在? | new File(path).exists() 返回 true |
路徑協議? | file:///data/data/包名/files/xxx.html |
JS 開關? | setJavaScriptEnabled(true) |
文件訪問? | setAllowFileAccess(true) |
外部存儲? | 加 requestLegacyExternalStorage="true" 或放私有目錄 |
? 最小可用代碼(私有目錄)
WebView webView = findViewById(R.id.webView);
WebSettings ws = webView.getSettings();
ws.setJavaScriptEnabled(true);
ws.setAllowFileAccess(true);File htmlFile = new File(getFilesDir(), "web/index.html");
webView.loadUrl("file://" + htmlFile.getAbsolutePath());
🎯 一句話總結
ERR_ACCESS_DENIED
99% 是 路徑不在白名單 或 沒開 setAllowFileAccess(true)
;把文件放進 私有目錄 或 assets 并按上面設置即可秒解。
net::ERR_ADDRESS_UNREACHABLE
code=-2
/ desc=net::ERR_ADDRESS_UNREACHABLE
不是 WebView 的 BUG,而是 TCP 三層無法與目標地址建立連接 的通用報錯。
把常見成因做成「檢查清單」,按順序 1→7 秒定位即可。
🔍 1. 地址寫錯 / 端口未監聽
- 用系統瀏覽器訪問 同一 URL,若也打不開 → 服務器或地址問題。
- 檢查 IP、端口、大小寫、http/https。
🔍 2. 本機網絡不通
- 手機 飛行模式 / VPN / 代理 忘記關。
- 電腦熱點 無 Internet。
- 公司/校園網 禁止設備互訪。
🔍 3. DNS 解析失敗
- 域名拼錯 →
ping 域名
返回unknown host
。 - 手機 DNS 被污染 → 換 8.8.8.8 再測。
🔍 4. 防火墻 / 安全組攔截
- 服務器 未放行端口 →
telnet IP 端口
連不上。 - 本地防火墻(Windows Defender / macOS 防火墻)阻斷入站。
🔍 5. 局域網地址在模擬器里寫錯
- 模擬器 不是真機,
localhost
/127.0.0.1
指向 模擬器自己。 - 正確寫法:
http://10.0.2.2:端口
(Android 模擬器專用宿主機地址)。
🔍 6. 公司/測試 Wi-Fi 開啟 AP 隔離
- 路由器后臺 → 關閉 AP 隔離 即可設備互通。
🔍 7. 代理 / VPN 全局攔截
- 關閉 Charles / Fiddler / Clash 全局代理再測。
- WebView 加代理頭:
webView.setWebViewClient(new WebViewClient() {@Overridepublic boolean shouldOverrideUrlLoading(WebView view, String url) {return false; // 不攔截} });
? 最小排查腳本(adb 快速驗證)
adb shell ping -c 3 192.168.x.x # 先看網絡通不通
adb shell curl -I http://192.168.x.x:3000
能通 → 代碼問題;不通 → 網絡問題。
? WebView 側兜底處理
webView.setWebViewClient(new WebViewClient() {@Overridepublic void onReceivedError(WebView view,WebResourceRequest request,WebResourceError error) {if (error.getErrorCode() == ERROR_HOST_LOOKUP ||error.getErrorCode() == ERROR_CONNECT ||error.getErrorCode() == ERROR_TIMEOUT) {view.loadUrl("file:///android_asset/offline.html");}}
});
🎯 一句話總結
ERR_ADDRESS_UNREACHABLE
= 地址不可達,按「瀏覽器能否打開 → 模擬器地址是否正確 → 防火墻/代理是否攔截 → DNS 是否解析」四步排查,99% 秒定位。
net::ERR_FILE_NOT_FOUND
code=-1
/ desc=net::ERR_FILE_FOUND
并不是網絡錯誤,而是 WebView 訪問本地文件時路徑寫錯或文件根本不存在。
按下面 4 步一次性排查:
? 1. 文件真的存在嗎?
File f = new File(path);
Log.d("WEBVIEW", "exists=" + f.exists() + " abs=" + f.getAbsolutePath());
若 exists=false
→ 路徑拼錯 / 沒拷進去 / 大小寫錯誤。
? 2. 路徑前綴必須拼對
位置 | 正確前綴 | 示例 |
---|---|---|
私有目錄 getFilesDir() | file:///data/data/包名/files/... | file:///data/data/com.demo/files/web/index.html |
外部存儲(SD) | file:///storage/emulated/0/... | file:///storage/emulated/0/Android/com.demo/web/index.html |
assets | file:///android_asset/... | file:///android_asset/web/index.html |
常見拼寫錯誤
? file://data/...
(少一個 /
)
? file:///android_assets/...
(多了 s
)
? 3. 空格 / 中文 / 特殊字符
本地文件含空格或中文 → URLEncoder 編碼:
String path = new File(dir, "index 1.html").getAbsolutePath();
path = Uri.encode(path); // 空格→%20
webView.loadUrl("file://" + path);
? 4. 用 FileProvider 給路徑(推薦 Android 7+)
防止 file://
被禁止,統一用 content://
:
<!-- AndroidManifest.xml -->
<providerandroid:name="androidx.core.content.FileProvider"android:authorities="${applicationId}.fileprovider"android:exported="false"android:grantUriPermissions="true"><meta-dataandroid:name="android.support.FILE_PROVIDER_PATHS"android:resource="@xml/file_paths" />
</provider>
res/xml/file_paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths><files-path name="web" path="web/" />
</paths>
Java 代碼:
File htmlFile = new File(getFilesDir(), "web/index.html");
Uri uri = FileProvider.getUriForFile(this,BuildConfig.APPLICATION_ID + ".fileprovider",htmlFile);
webView.loadUrl(uri.toString());
? 5. 兜底日志(復制即用)
webView.setWebViewClient(new WebViewClient() {@Overridepublic void onReceivedError(WebView view,WebResourceRequest request,WebResourceError error) {Log.e("WEBVIEW", "code=" + error.getErrorCode()+ " desc=" + error.getDescription()+ " url=" + request.getUrl().toString());}
});
打印出的 url
就是 WebView 實際訪問的地址,直接拷到瀏覽器/文件管理器 即可驗證是否存在。
🎯 一句話總結
ERR_FILE_NOT_FOUND
= 路徑錯 or 文件不在,用 File.exists()
確認 → 拼對 file:///...
→ 特殊字符 Uri.encode()
→ 推薦 FileProvider
一步到位。
好用的開發工具
推薦理由
postman在國內使用已經越來越困難:
1、登錄問題嚴重
2、Mock功能服務基本沒法使用
3、版本更新功能已很匱乏
4、某些外力因素導致postman以后能否使用風險較大
5、postman會導致電腦卡頓,而且使用的功能越多越慢,尤其是win電腦,太讓人郁悶了
出于以上考慮因此筆者自己開發了一款api調試開發工具SmartApi,滿足基本日常開發調試api需求
SmartApi
win版本不大于1M;運行消耗性能極低
macos 版本不大于100M;運行消耗性能極低
非常適合開發設備或性能有限的開發環境
SmartApi只為開發服務
官網地址SmartApi
http://www.smartapi.site/