更新记录

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 实现:

  1. 文件下载:用 HttpURLConnection 下载文件到本地,支持大文件、HTTPS、超时、断点重试
  2. 本地 HTTP 服务器:用 ServerSocket 在本地启动 HTTP 服务,把 _doc 目录暴露成 http://localhost:端口/,供 H5 页面通过 <audio><img> 等标签播放本地文件
  3. 目录浏览:内置简洁的目录列表页

目录


为什么需要这个插件

uni-app / HTML5+ 在 WebView 环境里对文件下载和本地文件访问存在诸多限制:

原生能力 问题
plus.downloader 部分环境返回空壳对象,事件不触发
uni.downloadFile 下载后只能保存到临时目录,无法指定路径
plus.io.FileWriter 大文件写入回调不触发或抛异常
uni.getFileSystemManager 部分基座不存在

cy-native-downloader 完全绕过这些 API,直接调用 Android 原生的 HttpURLConnectionServerSocket,稳定可靠。

平台支持

平台 支持情况
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.jsonapp-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 原生插件,标准基座不包含。必须:

  1. 运行 → 运行到手机或模拟器 → 制作自定义调试基座 → 传统打包
  2. 等 10-20 分钟
  3. 运行 → 运行到手机或模拟器 → 运行基座选择 → 自定义调试基座
  4. 再次运行项目

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 框架资源 iconsplashapp_name
DCloud 主题 DCloudSplashThemeDCloudActivityTheme
三方 SDK prevnextplay_pausecover_bg

错误示例(会报 Duplicate resources):

<!-- 危险,会和框架启动图冲突 -->
<ImageView android:src="@drawable/splash" />

正确示例

<ImageView android:src="@drawable/zp_notify_default_cover" />

VectorDrawable

支持两种图片格式:

  1. PNG / WebP:位图,直接放 drawable-xxhdpi/
  2. 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),内容必须是 格式,Android 才能识别。

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())

更新播放/暂停图标

playerplay / 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,混合内容被拦截。

解决

  1. 删掉 H5 页面里的 <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
  2. manifest.json 加:
"app-plus": {
    "webView": {
        "mixedContentMode": "alwaysAllow"
    }
}

Q4:下载失败,errMsg: HTTP 404

原因:URL 无效,或文件已被删除。

解决:把 URL 复制到浏览器验证是否可访问。

Q5:Duplicate resources 编译错误

原因nativeResources 下的资源名与框架冲突。

解决:所有自定义资源加独特前缀(如 zp_),绝不使用 iconsplashapp_nameDCloud* 等框架常用名。

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/ 下是否有对应文件,文件名大小写敏感。

注意事项

  1. 必须用自定义基座:UTS 插件在标准基座里不生效。

  2. rootPath 必须是绝对路径:用 plus.io.convertLocalFileSystemURL('_doc/') 转换。

  3. localhost 是本地回环:手机浏览器访问 http://127.0.0.1:8080,电脑浏览器访问不到手机上的服务。

  4. 目录不存在时不会自动建downloadFile 会自动 mkdirs() 创建父目录,但 startServerrootPath 必须真实存在。

  5. 改原生代码必须重做基座:任何 .kt.utsmanifest.jsonnativeResources 的改动,都必须重新制作自定义调试基座。

  6. 端口默认 8080:如果被别的 App 占用,会自动试 8081、8082…… 通过 getServerInfo().port 拿实际端口。

  7. HTTP 服务器支持 keep-alive 和 Range:适合音频流式播放,拖动进度条会发 206 部分请求。

  8. H5 侧播放 URL 拼接:本地文件 _doc/download/song.mp3 对应的 URL 是 http://localhost:8080/download/song.mp3(去掉 _doc/ 前缀)。

  9. 不要用 @drawable/splash:会和框架启动图冲突,直接打包失败。

  10. VectorDrawable 文件名_svg 后缀是命名习惯,文件实际是 .xml,不是 .svg

版本历史

1.0.0

  • 初始版本
  • 支持 HTTP/HTTPS 文件下载
  • 支持本地 HTTP 服务器
  • 支持 Range 请求和 keep-alive
  • 支持目录浏览
  • 支持远程资源在 nativeResources 中打包

相关链接

License

MIT

隐私、权限声明

1. 本插件需要申请的系统权限列表:

"<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\"/>"

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

暂无用户评论。