Android 11+ 下 APK 更新安装失败

从 ENOENT 到 DownloadManager + content URI

Posted by Trojan on May 28, 2026

Android 11+ 下 APK 更新安装失败:从 ENOENT 到 DownloadManager + content URI

最近排查了一个 App 内更新问题:版本检查正常,APK 也能完整下载,但调用系统安装器时始终失败,报错类似:

open failed: ENOENT (No such file or directory)

这个问题很容易让人误判成“文件没有下载成功”或“后端下载地址不对”。但最终定位下来,真正原因不是后端,也不是 APK 文件损坏,而是:

Android 11+ 的分区存储限制下,系统安装器无法可靠读取 App 私有目录里的 APK 文件。

本文整理这次排障过程、错误原因,以及最终采用的解决方案:使用 Android 系统 DownloadManager 下载 APK,并通过 content:// URI 拉起系统安装器。

为避免泄露业务信息,文中的接口、包名、文件名、日志、路径均已脱敏或泛化。

现象

App 内更新的整体流程大致是:

  1. App 启动或用户手动点击“检查更新”。
  2. 请求后端版本检查接口。
  3. 如果存在新版本,返回 APK 下载地址、版本号、文件大小等信息。
  4. App 下载 APK。
  5. 下载完成后调用系统安装器安装 APK。

前几步都正常,日志可以看到:

update check success
hasUpdate: true
versionCode: 105
fileSize: 15328261

APK 下载也正常:

download success
localPath: _doc/update/app-105.apk
size: 15328261

但一到安装阶段就失败:

install failed
open failed: ENOENT (No such file or directory)

这个错误非常迷惑,因为文件明明已经下载完成,而且从 App 自己的上下文里看,路径也确实存在。

初始方案:下载到 App 私有目录后直接安装

在 uni-app / HTML5+ 这类运行环境中,常见写法是使用 plus.downloader 下载文件,再调用 plus.runtime.install 安装:

const task = plus.downloader.createDownload(downloadUrl, {
  filename: '_doc/update/app-105.apk',
})

task.addEventListener('statechanged', (downloadTask, status) => {
  if (downloadTask.state === 4 && status === 200) {
    plus.runtime.install(downloadTask.filename)
  }
})

task.start()

这类代码在早期 Android 版本或部分设备上可能是可用的,但在 Android 11+ 上会遇到兼容性问题。

我们尝试过几个路径:

_doc/update/app-105.apk
_downloads/app-105.apk
file:///storage/emulated/0/Android/data/<app-package>/files/update/app-105.apk

也尝试过把 plus 协议路径转换成绝对物理路径,再传给安装 API。

结果仍然报:

open failed: ENOENT

为什么明明有文件,却报 ENOENT?

关键点在于:

“App 自己能读到这个文件”不等于“系统安装器也能读到这个文件”。

当 APK 下载到 App 私有目录时,实际路径通常类似:

/storage/emulated/0/Android/data/<app-package>/files/update/app-105.apk

或者在调试基座中类似:

/storage/emulated/0/Android/data/<debug-shell-package>/...

这些目录属于当前 App 的私有外部存储目录。

安装 APK 时,真正读取 APK 文件的并不是当前 App,而是 Android 系统安装器,也就是另一个进程。Android 11+ 引入更严格的分区存储后,另一个进程不能随便读取当前 App 私有目录里的文件。

所以会出现这种现象:

  • 当前 App 下载成功。
  • 当前 App 能拿到本地路径。
  • 当前 App 甚至可能能 resolve 这个路径。
  • 但系统安装器拿到这个裸 file:// 路径后读不到文件。
  • 最终报 ENOENT

这个 ENOENT 容易误导人,因为它并不是说“文件在 App 视角下不存在”,而是说“安装器进程按这个路径打不开文件”。

为什么不建议直接加 MANAGE_EXTERNAL_STORAGE?

排查过程中,一个容易想到的方案是加 Android 的“所有文件访问权限”:

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

但这个方案不适合普通 App 更新场景。

MANAGE_EXTERNAL_STORAGE 主要用于文件管理器、备份工具、杀毒工具等确实需要管理全盘文件的应用。对于“下载一个 APK 并交给系统安装器”这种场景,它过重了。

使用它会带来几个问题:

  • 权限申请链路复杂。
  • 用户体验差。
  • 应用市场审核风险高。
  • 不符合最小权限原则。
  • 即使加了,也不一定能解决安装器读取 URI 的所有兼容性问题。

更合理的方案是:不要让系统安装器读取 App 私有目录里的裸文件路径,而是使用 Android 官方推荐的可授权 URI。

正确方向:用 content URI 交给系统安装器

Android 7.0 之后,跨 App 共享文件就不推荐直接暴露 file:// 路径,而应该使用 content:// URI,并通过 Intent 临时授权读取权限。

常见方案有两种:

  1. 使用 FileProvider 暴露 App 私有文件。
  2. 使用系统 DownloadManager 下载到公共下载目录,然后拿系统提供的 content://downloads/... URI。

这次最终采用的是第二种。

原因是:

  • 下载行为本身就适合交给系统下载管理器。
  • APK 位于系统公共 Downloads 目录,安装器读取更稳定。
  • DownloadManager 可以返回系统管理的 content:// URI。
  • 不需要申请 MANAGE_EXTERNAL_STORAGE
  • 对 Android 11+ 更友好。

最终方案概览

最终流程改成:

版本检查成功
  ↓
使用 Android DownloadManager 下载 APK 到公共 Downloads
  ↓
轮询 DownloadManager 下载状态
  ↓
下载成功后拿到 content://downloads/... URI
  ↓
使用 Intent.ACTION_VIEW + FLAG_GRANT_READ_URI_PERMISSION 拉起系统安装器
  ↓
用户确认安装

关键日志类似:

DownloadManager started
{
  "downloadId": 118,
  "filename": "app-105.apk"
}
DownloadManager finished
{
  "downloadId": 118,
  "uri": "content://downloads/all_downloads/118",
  "downloaded": 15328261
}
Android install intent starting
{
  "uri": "content://downloads/all_downloads/118"
}
Android install intent started
{
  "uri": "content://downloads/all_downloads/118"
}

当能看到 Android install intent started,一般说明系统安装器已经成功被拉起。

关键实现 1:使用 DownloadManager 下载 APK

以下代码是脱敏后的核心思路,具体项目中需要根据运行时环境调整类型声明和异常处理。

const APK_MIME = 'application/vnd.android.package-archive'

function downloadApkWithDownloadManager(downloadUrl: string, versionCode: number): Promise<string> {
  return new Promise((resolve, reject) => {
    try {
      const android = plus.android
      const main = android.runtimeMainActivity()

      const DownloadManager = android.importClass('android.app.DownloadManager')
      const Uri = android.importClass('android.net.Uri')
      const Environment = android.importClass('android.os.Environment')
      const Context = android.importClass('android.content.Context')

      const filename = `app-${versionCode}-${Date.now()}.apk`
      const request = new DownloadManager.Request(Uri.parse(downloadUrl))

      request.setTitle(filename)
      request.setDescription('正在下载新版本')
      request.setMimeType(APK_MIME)
      request.setNotificationVisibility(
        DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
      )
      request.setDestinationInExternalPublicDir(
        Environment.DIRECTORY_DOWNLOADS,
        filename
      )

      const manager = main.getSystemService(Context.DOWNLOAD_SERVICE)
      android.importClass(manager)

      const downloadId = manager.enqueue(request)

      console.info('DownloadManager started', { downloadId, filename })

      waitDownloadFinished(android, manager, downloadId)
        .then(resolve)
        .catch(reject)
    } catch (error) {
      reject(error)
    }
  })
}

这里有几个关键点:

  • 使用系统 DownloadManager,而不是只用 App 私有目录下载。
  • setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) 指定公共下载目录。
  • setMimeType(APK_MIME) 告诉系统这是 APK。
  • 下载完成后不要拼裸 file:// 路径,而是从 DownloadManager 查询系统 URI。

关键实现 2:轮询下载结果并拿到 content URI

DownloadManager.enqueue() 返回的是一个 downloadId,需要用它查询下载状态。

function waitDownloadFinished(
  android: any,
  manager: any,
  downloadId: number
): Promise<string> {
  return new Promise((resolve, reject) => {
    const DownloadManager = android.importClass('android.app.DownloadManager')

    const timer = setInterval(() => {
      let cursor: any = null

      try {
        const query = new DownloadManager.Query()
        query.setFilterById(downloadId)

        cursor = manager.query(query)
        android.importClass(cursor)

        if (!cursor.moveToFirst()) return

        const statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
        const downloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)

        const status = cursor.getInt(statusIndex)
        const downloaded = cursor.getLong(downloadedIndex)

        if (status === DownloadManager.STATUS_SUCCESSFUL) {
          clearInterval(timer)

          const uri = manager.getUriForDownloadedFile(downloadId)
          android.importClass(uri)

          const uriString = uri.toString()

          console.info('DownloadManager finished', {
            downloadId,
            uri: uriString,
            downloaded,
          })

          resolve(uriString)
          return
        }

        if (status === DownloadManager.STATUS_FAILED) {
          clearInterval(timer)
          reject(new Error('APK 下载失败'))
        }
      } catch (error) {
        clearInterval(timer)
        reject(error)
      } finally {
        try {
          cursor?.close?.()
        } catch {
          // ignore
        }
      }
    }, 500)
  })
}

这里有一个 uni-app / HTML5+ 环境下很重要的细节:

android.importClass(cursor)

如果不显式包装 Android 原生对象,可能会遇到:

cursor.moveToFirst is not a function

同理,DownloadManagerUriPackageManager 等对象也可能需要按运行环境进行显式包装。

关键实现 3:用 Intent 拉起系统安装器

拿到 content://downloads/... 之后,用 Android Intent 打开:

function installApkByIntent(uriString: string): Promise<void> {
  return new Promise((resolve, reject) => {
    try {
      const android = plus.android
      const main = android.runtimeMainActivity()

      const Intent = android.importClass('android.content.Intent')
      const Uri = android.importClass('android.net.Uri')

      const intent = new Intent(Intent.ACTION_VIEW)
      intent.setDataAndType(Uri.parse(uriString), APK_MIME)
      intent.addCategory(Intent.CATEGORY_DEFAULT)
      intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
      intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

      console.info('Android install intent starting', { uri: uriString })

      main.startActivity(intent)

      console.info('Android install intent started', { uri: uriString })

      resolve()
    } catch (error) {
      reject(error)
    }
  })
}

关键点是:

intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

这表示临时授予接收方读取这个 content:// URI 的权限。

否则安装器可能能被打开,但读取 APK 时失败。

Android 8+ 的未知来源安装权限

Android 8.0 之后,“允许安装未知来源应用”从全局开关变成了按应用授权。

也就是说,即使 APK 下载和 URI 都正确,如果当前 App 没有安装未知来源应用的权限,安装流程也会被系统拦截。

可以在安装前检查:

function canRequestPackageInstalls(android: any, main: any): boolean {
  try {
    const packageManager = main.getPackageManager()
    android.importClass(packageManager)

    if (typeof packageManager.canRequestPackageInstalls !== 'function') {
      return true
    }

    return packageManager.canRequestPackageInstalls()
  } catch {
    return true
  }
}

如果没有权限,可以引导用户打开设置:

const Settings = android.importClass('android.provider.Settings')
const Uri = android.importClass('android.net.Uri')
const Intent = android.importClass('android.content.Intent')

const settingsIntent = new Intent(
  Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
  Uri.parse(`package:${main.getPackageName()}`)
)

main.startActivity(settingsIntent)

注意这里也有一个运行时细节:main.getPackageManager() 返回的也是 Android 原生对象,在 HTML5+ 环境下可能需要:

android.importClass(packageManager)

否则可能出现:

main.getPackageManager(...).canRequestPackageInstalls is not a function

为什么 DownloadManager 方案更稳定?

因为它改变了 APK 文件的“交接方式”。

原来的方式是:

App 下载到自己的私有目录
  ↓
把 file:// 或私有路径交给系统安装器
  ↓
安装器跨进程读取失败

新的方式是:

系统 DownloadManager 下载到公共 Downloads
  ↓
系统提供 content://downloads/... URI
  ↓
Intent 临时授权安装器读取
  ↓
安装器按 URI 读取成功

这更符合 Android 现代存储模型:

  • 不依赖裸文件路径。
  • 不暴露 App 私有目录。
  • 不需要申请所有文件权限。
  • 由系统组件管理下载文件。
  • 由系统 URI 权限机制完成跨进程共享。

排查过程中的几个误区

误区一:看到 ENOENT 就认为文件没下载成功

ENOENT 只说明当前读取方打不开这个路径。

如果读取方是系统安装器,那么它失败并不代表 App 自己看不到文件。

排查时应该分别确认:

  • 下载任务是否成功。
  • 文件大小是否匹配服务端记录。
  • 安装器拿到的路径是什么。
  • 安装器拿到的是 file:// 还是 content://

误区二:把所有异常都提示成“下载失败”

最初代码里把下载和安装放在同一个 try/catch 中:

try {
  const path = await download(url)
  await install(path)
} catch {
  showToast('下载失败')
}

这样安装失败也会被误报成下载失败。

建议拆开:

let path = ''

try {
  path = await download(url)
} catch (error) {
  console.warn('download failed', error)
  showToast('下载失败')
  return
}

try {
  await install(path)
} catch (error) {
  console.warn('install failed', error)
  showToast('安装失败')
}

这一步非常关键,否则排查方向很容易跑偏。

误区三:以为公共 Download 目录就一定要 MANAGE_EXTERNAL_STORAGE

使用 DownloadManager 下载到公共 Downloads,并通过系统返回的 content:// URI 安装,不等于 App 要管理整个外部存储。

它不应该优先依赖 MANAGE_EXTERNAL_STORAGE

误区四:忽略 Android 原生对象包装

在 uni-app / HTML5+ 环境中,很多 Android 原生对象需要显式 importClass 后才能调用方法。

例如:

android.importClass(cursor)
android.importClass(packageManager)
android.importClass(uri)
android.importClass(manager)

否则可能遇到各种 xxx is not a function

建议的日志设计

这类问题非常依赖真机日志。建议至少记录以下信息。

下载开始:

DownloadManager started
- downloadId
- filename

下载完成:

DownloadManager finished
- downloadId
- uri
- downloaded bytes

安装开始:

Android install intent starting
- uri

安装器拉起成功:

Android install intent started
- uri

下载失败和安装失败要分开:

download failed
install failed

不要在日志中输出:

  • 用户 token。
  • 内网地址。
  • 真实租户 ID。
  • 真实用户 ID。
  • 真实业务接口完整 URL。
  • 生产环境包名或签名信息。

最终结论

这次问题的本质是 Android 存储和跨进程文件共享问题:

APK 下载成功,不代表系统安装器能读取 App 私有目录中的 APK 文件。

在 Android 11+ 上,如果把 APK 放在 App 私有目录,然后用裸 file:// 或物理路径交给系统安装器,很容易出现:

open failed: ENOENT

更稳妥的做法是:

  1. 使用 Android DownloadManager 下载 APK 到公共 Downloads。
  2. 通过 getUriForDownloadedFile(downloadId) 获取系统 content:// URI。
  3. 使用 Intent.ACTION_VIEW 拉起系统安装器。
  4. 添加 FLAG_GRANT_READ_URI_PERMISSION 临时授权读取。
  5. Android 8+ 额外处理未知来源安装权限。
  6. 下载异常和安装异常分开记录、分开提示。

这套方案不需要申请 MANAGE_EXTERNAL_STORAGE,也更符合 Android 现代文件访问模型。

简化版流程图

App 检查更新
  ↓
服务端返回新版本信息和 APK 下载地址
  ↓
DownloadManager.enqueue(request)
  ↓
系统下载到公共 Downloads
  ↓
DownloadManager.STATUS_SUCCESSFUL
  ↓
getUriForDownloadedFile(downloadId)
  ↓
content://downloads/all_downloads/<id>
  ↓
Intent.ACTION_VIEW
  + application/vnd.android.package-archive
  + FLAG_GRANT_READ_URI_PERMISSION
  ↓
系统安装器读取 APK
  ↓
用户确认安装

如果你的 App 内更新也遇到类似 ENOENT,尤其是 Android 11+ 真机上复现,建议优先检查安装阶段传给系统安装器的是不是 App 私有目录里的裸文件路径。