
1. H5获取手机图片的两种主流姿势先分清再动手做Hybrid App开发这几年几乎每个项目都会遇到同一个需求H5页面要传图片用户点击按钮要么直接调起相机拍照要么打开系统相册选图。Android这边如果用的是WebView还得让原生层把图片数据送回JS回调。这个需求听起来简单但实际踩坑一堆尤其是“多张照片回传H5”这个环节很多人被卡在这里。先梳理一下H5获取相机或相册图片的整体思路主要就两条路线第一条纯H5方案直接在页面里写input typefile acceptimage/*通过给input加capture属性控制是打开相机还是相册再由WebView自身或者系统浏览器处理后续选择流程。这个方案实现代价最低H5页面不用跟原生交互但控制力最弱尤其是Android WebView里不同机型、不同WebView版本对capture属性的解析天差地别还会遇到“点按钮没反应”、“选完照片拿不到回调”这类问题。第二条原生接管方案H5通过JSBridge调用Android原生方法原生自己拉起相机拍照或打开相册选图拿到图片之后转成base64字符串或者可访问的路径再通过WebView的loadUrl或evaluateJavascript执行一段JS回调函数把数据传给H5。这条路线灵活度高能自定义拍照界面、控制图片压缩、支持一次选多张几乎所有成熟App最后都走到这条路上来。不过很多刚接触这块开发的人会纠结既然input标签就能搞定为什么还要让原生参与我的建议是如果你的H5页面只是临时用、对体验要求不高用input方案最快但如果这是长期迭代的业务功能建议一开始就做原生接管因为后续一定会遇到图片压缩、多选、权限适配、文件路径获取这些单靠H5躲不掉的硬需求到时候再改造成本更高。下面我把两条路线的实现细节分别拆开讲重点放在Android通过WebView把图片数据回传给H5的完整流程上。无论你是做Android原生开发的还是写H5页面的按这个思路做都能少走弯路。2. 纯H5方案靠谱吗input标签的兼容性真相2.1input capture属性在不同Android机型上的表现纯H5方案之所以简单就是因为不用碰原生代码一个input标签全搞定。但Android WebView并不是标准浏览器capture属性在这里经常失灵。先说标准用法!-- 只调起相机拍照 -- input typefile acceptimage/* captureenvironment idcameraInput !-- 打开相册选图 -- input typefile acceptimage/* idalbumInput !-- 支持多选 -- input typefile acceptimage/* multiple idmultiInputcapture属性的官方语义是当值为environment时直接打开系统相机App后置摄像头值为user时打开前置摄像头如果完全不加capture系统通常会弹出选择框让用户二选一或者直接打开相册。但实测下来这个逻辑在国产ROM的WebView里根本不受控。有些定制系统浏览器比如华为、小米、OPPO的某些版本无论capture设置成什么值都只打开相册另一些系统即使写对了environment用户点击一次后仅仅出现“相机/相册/文件”的系统底部弹窗根本不是预期行为。这就是为什么很多H5开发者抱怨“我明明写了capture但就是调不起相机”。2.2 WebView如何感知input选择结果如果坚持用纯H5方案WebView端得做一件事才能拿到结果重写onShowFileChooser方法。这个方法在用户点击input typefile时被WebView回调由这个方法返回的ValueCallbackUri[]最终承载用户选择的图片Uri。webView.setWebChromeClient(new WebChromeClient() { Override public boolean onShowFileChooser(WebView webView, ValueCallbackUri[] filePathCallback, FileChooserParams fileChooserParams) { if (mFilePathCallback ! null) { mFilePathCallback.onReceiveValue(null); } mFilePathCallback filePathCallback; // 根据fileChooserParams判断是否支持多选 boolean isMultiple fileChooserParams.getMode() FileChooserParams.MODE_OPEN_MULTIPLE; // 创建Intent拉起系统选择器 Intent intent fileChooserParams.createIntent(); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType(image/*); try { startActivityForResult(Intent.createChooser(intent, 选择图片), REQUEST_CODE_CHOOSE); } catch (ActivityNotFoundException e) { mFilePathCallback null; return false; } return true; } });这样处理后用户在H5页面点击上传按钮时确实是系统选择器接管选完图片后ActivityResult会返回一个Uri数组。但注意这里拿到的数据还在原生层H5的input事件并没有自动触发你还需要在onActivityResult里把图片数据“喂”回WebView让H5感知到用户已经选了图。这一步有两个选择一是手动构造一个JS事件或者调用H5暴露出来的回填函数二是利用filePathCallback.onReceiveValue(uris)回传Uri数组系统自带的WebView会自动触发Input标签的change事件H5的onchange就能拿到File对象。第二种是大多数情况下最省事的实现但有个Bug隐患如果提前把onShowFileChooser拦截了又没正确回调H5的onchange永远不触发页面一直转圈。纯H5方案还有一个问题拿不到高清大图的真实路径。即使onchange事件里能获取到File对象也只是一个临时拷贝而且这个File对象受Android私有目录限制业务方想把它直接传到服务端经常遭遇“文件不存在”或者“路径无效”的异常。这就是为什么后来我逐步放弃纯H5方案转向原生接管。3. 原生接管方案先搭好WebView与JSBridge环境3.1 用addJavascriptInterface还是evaluateJavascript原生接管的本质就是让H5通过JSBridge调原生方法原生完成拍照/选图后回传数据。选型上最常见的有两种addJavascriptInterface注入Java对象以及WebViewClient拦截prompt方法模拟通信。addJavascriptInterface是官方推荐方式优点是简单、容易理解JS端直接调用注入对象的方法即可。但它有个臭名昭著的漏洞史——Android 4.2之前存在严重的安全风险所以现在高版本强制要求方法必须用JavascriptInterface注解暴露给JS端低版本设备干脆不要用这个方案。更推荐的姿势其实是evaluateJavascript。原生拍照选图完成后通过webView.evaluateJavascript(javascript:window.callbackName( json ), null)直接把数据注入到页面的JS回调函数里。这个方案不用注入Java对象安全性和兼容性都更好而且evaluateJavascript是异步执行的不会阻塞UI线程大字符串传参也不会像loadUrl那样被截断。但要注意evaluateJavascript在Android 4.4以下不可用如果你的App还需要兼容老设备只能退回到loadUrl(javascript:...)这个方案对超长字符串支持不好图片base64动辄几百KB可能会被浏览器拦掉所以老设备上要么限制传参大小要么改用路径回传后面细讲。3.2 Native与H5的通信协议怎么设计通信协议这个事看起来简单但没设计好会被坑哭。我给你一个直接用得上的协议结构。H5侧定义一个全局回调函数// H5注册的回调函数 window.NativeBridge { onImageSelected: function(result) { // result是一个JSON字符串 // 格式{status:success,data:[{name:xxx.jpg,data:base64...},...]} var json JSON.parse(result); if (json.status success) { // 处理图片数据 var images json.data; for (var i 0; i images.length; i) { uploadImage(images[i]); } } } };原生端定义一个方法拉起相机或相册等结果返回后组装JSON并回调JavascriptInterface public void chooseImages(String callbackName, boolean multiple) { // 先保存回调函数名 this.mCallback callbackName; // 根据multiple决定单选还是多选 if (multiple) { openAlbumMulti(); } else { openAlbumSingleOrCamera(); } }这里最关键的一个设计原则JS回调函数名不要写死。很多团队图省事直接在原生代码里写死window.onImageSelected后来H5页面改成Vue或者React后函数作用域一变回调就直接失效。正确做法是让H5把回调函数名作为参数传进来原生只负责拼接并执行这段JS。3.3 Manifest权限与Android版本适配原生方案最头疼的就是权限。Android 6.0以下只需要在Manifest里声明uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE /Android 6.0到12运行时权限动态申请跑不掉READ_EXTERNAL_STORAGE和CAMERA都需要在代码里请求。Android 13及以上更狠READ_EXTERNAL_STORAGE被拆成了细化权限读图片需要单独申请READ_MEDIA_IMAGESif (Build.VERSION.SDK_INT 33) { requestPermissions(new String[]{Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.CAMERA}, REQUEST_PERMISSION); } else { requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.CAMERA}, REQUEST_PERMISSION); }这块不处理好用户点了相册按钮却看到一片空白列表或者点了拍照相机直接闪退都是权限没适配到位的典型症状。还有一点容易忽略Android 11API 30开始系统强化了包可见性限制。如果你的App要显式拉起系统相机App或系统相册App必须在Manifest里声明queries否则startActivityForResult可能抛ActivityNotFoundException:queries intent action android:nameandroid.media.action.IMAGE_CAPTURE / /intent intent action android:nameandroid.intent.action.GET_CONTENT / /intent intent action android:nameandroid.intent.action.OPEN_DOCUMENT / /intent /queries少写这块很多开发者会在Android 11的模拟器上遇到“点击没反应”看了半天代码找不到原因其实只是包可见性问题。4. 原生拍照与相册选图的完整链路4.1 拍照FileProvider与临时Uri的正确处理调用系统相机拍照的核心代码不复杂复杂度全在文件Uri的适配上。Android 7.0API 24之后file://协议的Uri直接跨应用传递会被系统拦截并抛FileUriExposedException所以必须用FileProvider生成content://协议的Uri。第一步在Manifest里注册FileProviderprovider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider第二步配置res/xml/file_paths.xml?xml version1.0 encodingutf-8? paths cache-path namecache path. / external-cache-path nameexternal_cache path. / external-files-path nameexternal_files path. / /paths第三步拉起相机private Uri mCameraUri; private void openCamera() { Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (intent.resolveActivity(getPackageManager()) ! null) { File photoFile createImageFile(); if (photoFile ! null) { mCameraUri FileProvider.getUriForFile(this, getPackageName() .fileprovider, photoFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, mCameraUri); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); startActivityForResult(intent, REQUEST_CODE_CAMERA); } } }这里有一个细节createImageFile()生成的文件要确保父目录存在我用的是getExternalCacheDir()因为这是应用私有的缓存目录不需要额外的存储权限而且不会被用户在其他文件管理器里乱翻到。拍照回调里相机可能把照片旋转过也可能直接返回一个缩略图取决于OEM实现。稳妥做法是用BitmapFactory.Options读取inSampleSize压缩后再做旋转矫正这个细节我会在第五节详细写。4.2 相册选图单选与多选的分歧点相册选图我一般推荐直接用系统相册的ACTION_GET_CONTENT或者ACTION_OPEN_DOCUMENT。区别在于ACTION_GET_CONTENT老API返回的Uri是临时的App结束可能失效但兼容性最好。ACTION_OPEN_DOCUMENTAndroid 4.4引入返回的Uri可以通过takePersistableUriPermission持久化业务需要长期访问这个文件时更好用。单选和多选的代码几乎一样只是多传一个EXTRA_ALLOW_MULTIPLEprivate void openAlbumMulti() { Intent intent new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType(image/*); intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); startActivityForResult(intent, REQUEST_CODE_ALBUM); }注意ACTION_OPEN_DOCUMENT配合EXTRA_ALLOW_MULTIPLE在部分老机型上可能会失效用户只能选一张。这种情况要么在代码里判断返回的ClipData是不是null如果是null就提示用户重新选择要么干脆自己做一套图片选择器。如果项目对多选体验要求高强烈建议集成成熟的开源图片选择框架比如PictureSelector、Matisse省很多适配功夫。4.3 拿到Uri后怎么解析成可用的图片数据这是原生方案最核心的环节。onActivityResult拿到的Uri分两种情况一种情况拍照返回的Uri是我们自己创建的mCameraUri这个Uri指向我们创建的临时文件直接用ContentResolver打开即可。另一种情况相册返回的Uri是系统返回的content://协议Uri也可能在某些老机型上是file://这个Uri直接传给H5往往不可用因为H5没有系统级别的读取权限。所以正确的做法是在原生层解析这个Uri拿到图片数据处理成H5能用的格式。解析Uri的标准姿势private Bitmap decodeUriToBitmap(Uri uri) throws IOException { BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; ContentResolver resolver getContentResolver(); BitmapFactory.decodeStream(resolver.openInputStream(uri), null, options); // 计算压缩比例限制最大尺寸为1280px int maxSize 1280; int sampleSize 1; while (options.outWidth / sampleSize maxSize || options.outHeight / sampleSize maxSize) { sampleSize * 2; } options.inJustDecodeBounds false; options.inSampleSize sampleSize; return BitmapFactory.decodeStream(resolver.openInputStream(uri), null, options); }注意decodeStream有个坑第一次inJustDecodeBounds解码时对InputStream有消耗第二次真正解码时如果还用同一个InputStream会返回null。要么重新openInputStream要么先把流读成字节数组再解码。上面代码示范的是重新open的方式。5. 多张照片回传H5Base64大法 vs 路径回传5.1 Base64回传的实现思路与体积控制拿到Bitmap之后最直接的回传方式就是转成base64字符串拼到JSON里送给H5。好处是H5侧不用再考虑文件路径问题拿到字符串直接new Image()就能预览或者传给服务端。代码不复杂private String bitmapToBase64(Bitmap bitmap) { ByteArrayOutputStream baos new ByteArrayOutputStream(); // 80%质量压缩 bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos); byte[] bytes baos.toByteArray(); return Base64.encodeToString(bytes, Base64.NO_WRAP); }然后组装结果JSONObject result new JSONObject(); JSONArray dataArray new JSONArray(); for (Bitmap bitmap : bitmaps) { JSONObject item new JSONObject(); item.put(name, System.currentTimeMillis() .jpg); item.put(data, bitmapToBase64(bitmap)); dataArray.put(item); } result.put(status, success); result.put(data, dataArray); // 回调H5 String js javascript: mCallback ( result.toString() ); webView.post(() - webView.evaluateJavascript(js, null));但base64方案有两个痛点。第一个是体积爆炸一张1MB的图片转成base64会膨胀到1.37MB左右如果一次选5张一个JSON字符串将近7MB通过JSBridge传到H5WebView的JS引擎处理起来会明显卡顿低端机上甚至直接OOM。第二个是传参被截断某些WebView版本对javascript:协议的长度有限制超长字符串传过去的直接被截断JS端解析报错。针对这两个痛点我的经验是如果图片数量少3张以内且单张压缩后控制在200KB以内用base64没问题如果图片数量多或者原图本身很大走路径回传更稳。5.2 路径回传方案与WebView资源拦截路径回传的思路是原生层把选中的图片压缩后保存到本地的缓存目录然后把图片的HTTP地址加载本地服务器或者content://Uri传给H5H5用这个地址直接渲染或上传。这里有两种落地方式。方式一在原生层起一个轻量级的本地静态文件服务比如通过NanoHTTPD库把图片存到指定目录然后拼成http://127.0.0.1:8080/image/xxx.jpg这样的地址回传给H5。H5拿到地址后充其量只能预览真正上传的时候还是要依赖原生去读取文件二进制再传给服务端所以这种方式更适合“预览上传联动”的场景。方式二用WebViewAssetLoader或自定义WebViewClient拦载特殊协议。定义一个如hybrid://image/xxx.jpg的scheme当WebView加载这个地址时shouldInterceptRequest被回调原生在这个方法里从本地文件读数据并返回给WebView。这个方案不需要起网络服务不占端口但要求H5侧把图片的src设置成这个自定义协议地址图片才能展示出来。这里说句实在话路径回传适合的典型场景是“原生拿到原图路径H5只负责展示预览图最终上传由原生把字节流传给服务端接口”。如果你们的业务是纯Web服务端直接收H5的multipart文件那路径方案反而绕了一圈base64反而直接。5.3 大文件上传的优化技巧不管用哪种回传方式大图问题躲不掉。拍照返回的图片动辄3MB以上不压缩直接进内存低端机瞬间OOM。我自己总结了一套压缩策略按顺序执行采样压缩用inSampleSize先把Bitmap解码到长边不超过2048px质量压缩转输出流时quality值从85起步如果文件仍然超过300KB降到70再试尺寸压缩如果质量压缩后还是太大把长边再压到1280px格式选择带透明通道的图片用PNG纯照片一律用JPEG还有个小技巧压缩后把图片保存到getCacheDir()下的upload目录文件名用UUID.randomUUID().toString()拼接时间戳避免多张图片重名导致的覆盖问题。等到图片成功上传到服务端之后再把缓存文件删掉防止存储空间膨胀。6. 常见问题与排查技巧实录6.1 H5页面onChange不回调图片选了但页面没反应这种现象多半是onShowFileChooser被拦截后没有正确回调filePathCallback。我要重点提醒filePathCallback.onReceiveValue(null)这个空回调很重要它相当于告诉WebView“这次请求被取消了”如果不写下一次点击上传按钮时onShowFileChooser携带的filePathCallback还是上一次的残留对象可能直接导致第二次选的图片回传给第一次的请求页面表现完全错乱。正确写法是每次进入onShowFileChooser先把旧的回调置空并覆盖Override public boolean onShowFileChooser(WebView webView, ValueCallbackUri[] filePathCallback, FileChooserParams fileChooserParams) { if (mFilePathCallback ! null) { mFilePathCallback.onReceiveValue(null); } mFilePathCallback filePathCallback; // 后续逻辑 }6.2 图片Uri拿到了但读流返回FileNotFoundException这个坑主要是相册返回的Uri即使授权了也不可能一直有效。ACTION_GET_CONTENT返回的临时的读权限只维持到当前Activity的任务栈结束如果App在后台被回收或者H5侧延迟了一段时间再去读这个Uri就会抛FileNotFoundException。解决方案有两种一是在拿到Uri后立刻把文件复制到应用私有目录后续全部基于本地文件操作二是使用ACTION_OPEN_DOCUMENT并调用contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)获取持久化访问权限这样才能在下次启动App时继续读这个文件。实测下来第一种最省心毕竟复制文件后就不依赖外部Uri了后续逻辑全部在自家沙盒里跑。6.3 多张图片回调时JS报错Unexpected token or SyntaxError这个问题我排查过好几次根子都在JSON字符串转义上。图片base64串里天然包含、/、号这些字符在拼接JS代码时如果没做转义生成的JS代码就变成了callback(data:image/jpeg;base64,/9j/4AAQ...)浏览器解析时遇到特殊字符直接报SyntaxError。解决办法是统一对JSON字符串做JS转义最稳的做法是转成JSON字符串之后再把字符串里反斜杠转义一遍或者用JSONObject.toString()后用TextUtils.htmlEncode处理一遍再包进单引号里。更省心的方案是用evaluateJavascript传参因为它本质不是拼接字符串只要参数本身格式正确就不会被截断或转义。6.4 WebView没有网络权限导致本地图片加载失败很多H5页面本身需要联网但原生WebView没开INTERNET权限或者H5的图片不走网络只是本地资源结果图片一直转圈加载不出来。这里分两种情况H5里的远程图片加载不了检查Manifest里有没有uses-permission android:nameandroid.permission.INTERNET /原生回传的图片地址是content://或者自定义schemeH5的img标签默认不会加载这种协议需要WebView做资源拦截这个坑在Android 9以下不突出Android 9以后默认禁止明文HTTP流量如果在本地起了HTTP服务回传图片路径H5加载http://地址会被拦需要配置android:usesCleartextTraffictrue或设置NetworkSecurityPolicy。6.5 最后整理一张速查表问题典型原因解决方案input标签调不起相机capture属性兼容性差改用原生JSBridge接管H5 onChange不触发mFilePathCallback未正确回调null或未调用onReceiveValue每次onShowFileChooser先置空旧回调相册Uri读取失败临时权限过期拿到Uri立即复制到私有目录多图base64回调JS报错特殊字符未转义使用evaluateJavascript传参或JSON转义图片过大OOM未做采样压缩BitmapFactory.Options设置inSampleSizeAndroid 7.0以上拍照崩溃file://协议暴露FileProvider包装成content://Android 13读相册失败READ_EXTERNAL_STORAGE被拆分为细化权限适配READ_MEDIA_IMAGES本地HTTP图片加载失败明文流量被限制配置usesCleartextTraffic或NetworkSecurityPolicy7. 一些实操中的个人体会原生和H5的图片交互说到底是一个“信任边界”问题。H5环境可控性低能做的事有限原生环境能力大但协作成本高。设计通信协议时我一直坚持的原则是原生只负责“取图”和“回传”不负责“解析”和“上传”。把职责边界划清楚后续业务迭代才不会互相牵制。另一个体会是能压缩就趁早压缩不要等H5那边去压缩。JS里做压缩看似方便但性能比原生差一个量级而且H5侧的Canvas压缩在部分Android WebView上会生成全黑的图片排查起来让人崩溃。原生用BitmapFactory和Matrix处理稳定且可控。我用这套方案落地过好几个项目从最早纯input标签的简易版到后来原生多选、拍照压缩、base64与路径双轨回传的完整版整体链路已经比较成熟了。如果你们项目里也需要这个能力完全可以按这篇文章的思路先搭一个最小可用版本再在业务推进中逐步补上权限适配、图片压缩和异常兜底。过程中遇到具体问题欢迎在评论区把报错信息发出来我看到了会回复。