更新记录
1.0.1(2026-09-20)
修复锁频播放一段时间停播
1.0(2026-09-18)
2026.09.18:发布初始版本。
平台兼容性
uni-app(5.26)
| Vue2 | Vue3 | Vue3插件版本 | Chrome | Safari | app-vue | app-nvue | Android | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|---|
| - | √ | 1.0.1 | √ | - | √ | - | √ | - | - |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| - | - | - | - | - | - | - | - | - | - | - | - |
cy-native-downloader
一个 uni-app / uni-app x 的 UTS 原生插件,基于 Android 原生 API 实现:
- 文件下载:用
HttpURLConnection下载文件到本地,支持大文件、HTTPS、超时、断点重试 - 本地 HTTP 服务器:用
ServerSocket在本地启动 HTTP 服务,把_doc目录暴露成http://localhost:端口/,供 H5 页面通过<audio>、<img>等标签播放本地文件 - 目录浏览:内置简洁的目录列表页
目录
为什么需要这个插件
uni-app / HTML5+ 在 WebView 环境里对文件下载和本地文件访问存在诸多限制:
| 原生能力 | 问题 |
|---|---|
plus.downloader |
部分环境返回空壳对象,事件不触发 |
uni.downloadFile |
下载后只能保存到临时目录,无法指定路径 |
plus.io.FileWriter |
大文件写入回调不触发或抛异常 |
uni.getFileSystemManager |
部分基座不存在 |
cy-native-downloader 完全绕过这些 API,直接调用 Android 原生的 HttpURLConnection 和 ServerSocket,稳定可靠。
平台支持
| 平台 | 支持情况 |
|---|---|
| Android | ✅ |
| iOS | ❌(暂未实现) |
| Web / 小程序 | ❌ |
最低版本:Android 5.0 (API 21)
安装
1. 通过插件市场导入
在 HBuilderX 插件市场搜索 cy-native-downloader 导入。
2. 手动安装
把 cy-native-downloader 目录放入项目的 uni_modules/ 目录下:
项目根/
└── uni_modules/
└── cy-native-downloader/
├── package.json
├── readme.md
└── utssdk/
├── interface.uts
└── app-android/
├── config.json
├── index.uts
└── src/main/java/uts/sdk/modules/cy_native_downloader/
└── HttpServer/
└── SimpleHttpServer.kt
3. 声明权限
在 manifest.json 的 app-plus.distribute.android.permissions 中确保有以下权限:
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>"
4. 必须使用自定义基座
本插件是 UTS 原生插件,标准基座不包含。必须:
- 运行 → 运行到手机或模拟器 → 制作自定义调试基座 → 传统打包
- 等 10-20 分钟
- 运行 → 运行到手机或模拟器 → 运行基座选择 → 自定义调试基座
- 再次运行项目
5. 导入插件
// #ifdef APP-PLUS
import { downloadFile, startServer, stopServer, getServerInfo, isRunning } from '@/uni_modules/cy-native-downloader'
// #endif
API 参考
downloadFile(options)
异步下载文件到指定路径。
参数:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
url |
String | ✅ | 文件下载地址,支持 HTTP 和 HTTPS |
savePath |
String | ✅ | 保存路径,必须用绝对路径(用 plus.io.convertLocalFileSystemURL 转换) |
success |
Function | ❌ | 成功回调,参数 { path, size } |
fail |
Function | ❌ | 失败回调,参数 { errMsg } |
success 回调参数:
| 字段 | 类型 | 说明 |
|---|---|---|
path |
String | 文件实际保存路径 |
size |
Number | 文件大小(字节) |
示例:
var absPath = plus.io.convertLocalFileSystemURL('_doc/download/song.mp3')
downloadFile({
url: 'https://a.xmcdn.com/xxx.m4a?sign=xxx',
savePath: absPath,
success: (res) => {
console.log('下载成功:', res.path, ' 大小:', res.size)
},
fail: (err) => {
console.error('下载失败:', err.errMsg)
}
})
startServer(options)
启动本地 HTTP 服务器。
参数:
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
port |
Number | 8080 | 首选端口,被占用时自动递增尝试 |
rootPath |
String | '' | 服务根目录。必须是绝对路径,例如用 plus.io.convertLocalFileSystemURL('_doc/') 获取 |
返回值:ServerInfo | null
{
state: 'running',
port: 8080,
baseUrl: 'http://localhost:8080',
rootPath: '/storage/emulated/0/.../doc'
}
示例:
var docRealPath = plus.io.convertLocalFileSystemURL('_doc/')
var info = startServer({
port: 8080,
rootPath: docRealPath
})
if (info) {
console.log('服务器已启动:', info.baseUrl)
} else {
console.error('启动失败')
}
stopServer()
停止服务器。对未运行的服务器安全,不会报错。
stopServer()
getServerInfo()
获取当前服务器信息。未运行时返回 null。
var info = getServerInfo()
if (info) {
console.log('端口:', info.port, ' 根目录:', info.rootPath)
}
isRunning()
检查服务器是否运行。
if (isRunning()) {
console.log('服务器在跑')
}
使用示例
示例 1:下载文件
import { downloadFile } from '@/uni_modules/cy-native-downloader'
function downloadToDoc(url, savePath) {
var absPath = plus.io.convertLocalFileSystemURL(savePath)
return new Promise((resolve, reject) => {
downloadFile({
url: url,
savePath: absPath,
success: resolve,
fail: reject
})
})
}
// 使用
downloadToDoc('https://example.com/a.mp3', '_doc/download/a.mp3')
.then(res => console.log('下载成功:', res.path))
.catch(err => console.error('下载失败:', err.errMsg))
示例 2:H5 通过本地服务器播放音频
主进程(App.vue):
import { startServer, stopServer } from '@/uni_modules/cy-native-downloader'
onLaunch() {
setTimeout(() => {
stopServer()
var info = startServer({
port: 8080,
rootPath: plus.io.convertLocalFileSystemURL('_doc/')
})
if (info) {
console.log('服务器已启动:', info.baseUrl)
}
}, 500)
}
H5 页面:
// 假设音频已下载到 _doc/download/song.mp3
var audio = new Audio('http://localhost:8080/download/song.mp3')
audio.play()
示例 3:目录浏览
启动服务器后,在手机浏览器访问:
http://127.0.0.1:8080/
会看到 _doc 目录的树状列表,可点击进入子目录或直接播放音频。
示例 4:跨 webview 通知下载
H5(web-view 内):
// 通过 plus.storage 通知主进程
plus.storage.setItem('native_dl_request', JSON.stringify({
id: 'dl_' + Date.now(),
type: 'download',
url: 'https://example.com/song.mp3',
savePath: '_doc/download/song.mp3'
}))
// 注册回调
window.onNativeDownloadComplete = function(result) {
if (result.ok) {
console.log('下载成功:', result.path)
} else {
console.error('下载失败:', result.msg)
}
}
主进程(pages/webview/webview.vue):
// 轮询 plus.storage
setInterval(() => {
var raw = plus.storage.getItem('native_dl_request')
if (!raw) return
var payload = JSON.parse(raw)
plus.storage.removeItem('native_dl_request')
downloadFile({
url: payload.url,
savePath: plus.io.convertLocalFileSystemURL(payload.savePath),
success: (res) => {
evalJS('window.onNativeDownloadComplete(' + JSON.stringify({
ok: true,
path: payload.savePath,
size: res.size
}) + ')')
},
fail: (err) => {
evalJS('window.onNativeDownloadComplete(' + JSON.stringify({
ok: false,
msg: err.errMsg
}) + ')')
}
})
}, 2000)
原生资源
插件支持在 nativeResources/android/res/ 下放置原生 Android 资源(布局、图片、ids 等),会随 App 一起打包。
目录结构
项目根/
└── nativeResources/
└── android/
└── res/
├── layout/ # RemoteViews 布局 XML
│ └── zp_notification_player.xml
├── drawable-xxhdpi/ # 图片和 VectorDrawable
│ ├── zp_ic_play_circle_svg.xml
│ ├── zp_ic_pause_circle_svg.xml
│ └── zp_notify_default_cover.png
└── values/
└── zp_music_ids.xml # id 定义
命名规范
所有文件名、id、布局引用都必须加独特前缀(例如 zp_),避免与以下框架资源冲突:
| 冲突源 | 示例 |
|---|---|
| uni-app 框架资源 | icon、splash、app_name |
| DCloud 主题 | DCloudSplashTheme、DCloudActivityTheme |
| 三方 SDK | prev、next、play_pause、cover_bg |
错误示例(会报 Duplicate resources):
<!-- 危险,会和框架启动图冲突 -->
<ImageView android:src="@drawable/splash" />
正确示例:
<ImageView android:src="@drawable/zp_notify_default_cover" />
VectorDrawable
支持两种图片格式:
- PNG / WebP:位图,直接放
drawable-xxhdpi/ - VectorDrawable XML:矢量图,文件扩展名是
.xml,放在drawable-xxhdpi/下,内容形如:
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="..."/>
</vector>
注意:关于矢量图标:Android 不支持直接放 .svg 扩展名的原始 SVG 文件。请先用工具(Android Studio 的 Vector Asset、SVG to VectorDrawable 在线工具等)转成 VectorDrawable XML,保存为 .xml 扩展名放进 drawable-xxhdpi/。转换后文件名可以保留 _svg 后缀作为命名习惯(如 zp_ic_play_svg.xml),内容必须是
ids 定义
values/zp_music_ids.xml 用于定义 @id/xxx(不带 +)能引用的 id:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item type="id" name="zp_root" />
<item type="id" name="zp_title" />
<item type="id" name="zp_artist" />
<item type="id" name="zp_play_pause" />
<!-- ... -->
</resources>
布局里用 @+id/zp_xxx 定义新 id,或 @id/zp_xxx 引用已存在的 id。
运行时读取资源 id
在 JS 里通过反射获取资源 id:
function getResId(name, type) {
var main = plus.android.runtimeMainActivity()
var res = main.getResources()
var pkg = main.getPackageName()
return plus.android.invoke(res, "getIdentifier", name, type, pkg)
}
// 读取布局
var layoutId = getResId("zp_notification_player", "layout")
// 读取图片
var iconId = getResId("zp_ic_play_circle_svg", "drawable")
// 读取 id
var titleId = getResId("zp_title", "id")
// 引用
var RemoteViews = plus.android.importClass("android.widget.RemoteViews")
var rv = new RemoteViews(pkg, layoutId)
rv.setTextViewText(titleId, "歌曲名")
rv.setImageViewResource(playPauseId, iconId)
命名必须和 nativeResources/android/res/ 下的文件名字符串完全一致。改了文件名,代码里所有引用都要跟着改。
自定义通知栏
插件支持配合 nativeResources 构建自定义 RemoteViews 通知。
布局文件
nativeResources/android/res/layout/zp_notification_player.xml:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/zp_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/zp_notify_default_cover"
android:padding="8dp">
<TextView
android:id="@+id/zp_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#ffffff" />
<TextView
android:id="@+id/zp_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#dddddd" />
<ProgressBar
android:id="@+id/zp_progress_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="4dp" />
<ImageButton
android:id="@+id/zp_play_pause"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/zp_ic_play_circle_svg" />
<!-- 其他按钮 ... -->
</FrameLayout>
创建通知
import zps.sixdeng.cn.R // 包名要与 manifest.json 一致
var main = plus.android.runtimeMainActivity()
var pkg = main.getPackageName()
var res = main.getResources()
// 读资源 id
function getResId(name, type) {
return plus.android.invoke(res, "getIdentifier", name, type, pkg)
}
var RemoteViews = plus.android.importClass("android.widget.RemoteViews")
var Icon = plus.android.importClass("android.graphics.drawable.Icon")
var BitmapFactory = plus.android.importClass("android.graphics.BitmapFactory")
var Notification = plus.android.importClass("android.app.Notification")
var Builder = plus.android.importClass("android.app.Notification$Builder")
// 图标
var iconResId = getResId("zp_ic_play_circle_svg", "drawable")
var bmp = BitmapFactory.decodeResource(res, iconResId)
var icon = Icon.createWithBitmap(bmp)
// RemoteViews
var rv = new RemoteViews(pkg, getResId("zp_notification_player", "layout"))
rv.setTextViewText(getResId("zp_title", "id"), "歌名")
rv.setTextViewText(getResId("zp_artist", "id"), "歌手")
rv.setImageViewResource(getResId("zp_play_pause", "id"), iconResId)
// 构建通知
var nm = main.getSystemService(main.NOTIFICATION_SERVICE)
var builder = new Builder(main, "music_player")
builder.setSmallIcon(icon)
builder.setCustomBigContentView(rv)
nm.notify(4321, builder.build())
更新播放/暂停图标
在 player 的 play / pause 事件里重建通知:
player.addEventListener('play', () => {
if (isScreenLocked()) {
refreshNotifyNow() // 重建通知,图标切到"暂停"
}
})
player.addEventListener('pause', () => {
if (isScreenLocked()) {
refreshNotifyNow() // 重建通知,图标切回"播放"
}
})
并在 SCREEN_ON / SCREEN_OFF 广播里也重建:
if (intent.getAction() === Intent.ACTION_SCREEN_ON) {
SCREEN_OFF_TAG = 0
refreshNotifyNow()
} else if (intent.getAction() === Intent.ACTION_SCREEN_OFF) {
SCREEN_OFF_TAG = 1
refreshNotifyNow()
}
常见问题
Q1:startServer 返回 null
原因:端口全被占用,或原生模块未加载。
解决:
- 确认使用了自定义调试基座
- 确认
manifest.json中有INTERNET权限 - 检查控制台是否有红色报错
Q2:http://localhost:8080/ 在手机浏览器打不开
原因:localhost 指向的是当前设备自己。必须在同一台手机上访问。
- ❌ 在电脑浏览器输入
http://localhost:8080→ 访问的是电脑,不是手机 - ✅ 在手机浏览器输入
http://127.0.0.1:8080
Q3:H5 里 <audio src="http://localhost:8080/..."> 播放失败
原因:H5 页面是 HTTPS,混合内容被拦截。
解决:
- 删掉 H5 页面里的
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests"> - 在
manifest.json加:
"app-plus": {
"webView": {
"mixedContentMode": "alwaysAllow"
}
}
Q4:下载失败,errMsg: HTTP 404
原因:URL 无效,或文件已被删除。
解决:把 URL 复制到浏览器验证是否可访问。
Q5:Duplicate resources 编译错误
原因:nativeResources 下的资源名与框架冲突。
解决:所有自定义资源加独特前缀(如 zp_),绝不使用 icon、splash、app_name、DCloud* 等框架常用名。
Q6:resource id/xxx not found
原因:布局里用了 @id/xxx,但 values/*.xml 里没有定义。
解决:
- 要么在
values/zp_music_ids.xml里加<item type="id" name="xxx" /> - 要么布局里改成
@+id/xxx(自动创建)
Q7:resource drawable/xxx not found
原因:布局里引用了不存在的图片。
解决:检查 nativeResources/android/res/drawable-xxhdpi/ 下是否有对应文件,文件名大小写敏感。
注意事项
-
必须用自定义基座:UTS 插件在标准基座里不生效。
-
rootPath必须是绝对路径:用plus.io.convertLocalFileSystemURL('_doc/')转换。 -
localhost是本地回环:手机浏览器访问http://127.0.0.1:8080,电脑浏览器访问不到手机上的服务。 -
目录不存在时不会自动建:
downloadFile会自动mkdirs()创建父目录,但startServer的rootPath必须真实存在。 -
改原生代码必须重做基座:任何
.kt、.uts、manifest.json、nativeResources的改动,都必须重新制作自定义调试基座。 -
端口默认 8080:如果被别的 App 占用,会自动试 8081、8082…… 通过
getServerInfo().port拿实际端口。 -
HTTP 服务器支持 keep-alive 和 Range:适合音频流式播放,拖动进度条会发 206 部分请求。
-
H5 侧播放 URL 拼接:本地文件
_doc/download/song.mp3对应的 URL 是http://localhost:8080/download/song.mp3(去掉_doc/前缀)。 -
不要用
@drawable/splash:会和框架启动图冲突,直接打包失败。 -
VectorDrawable 文件名:
_svg后缀是命名习惯,文件实际是.xml,不是.svg。
版本历史
1.0.0
- 初始版本
- 支持 HTTP/HTTPS 文件下载
- 支持本地 HTTP 服务器
- 支持 Range 请求和 keep-alive
- 支持目录浏览
- 支持远程资源在
nativeResources中打包
相关链接
License
MIT

收藏人数:
购买普通授权版(
试用
赞赏(0)
下载 0
赞赏 0
下载 12620476
赞赏 1949
赞赏
京公网安备:11010802035340号