ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android应用更新:基于DownloadManager与FileProvider的APK下载安装全解析

Android应用更新:基于DownloadManager与FileProvider的APK下载安装全解析 1. 项目概述一个纯粹的APK下载更新器在Android应用开发里版本更新功能几乎是每个App的标配。但很多时候我们会被各种复杂的流程和第三方SDK搞得晕头转向检查更新、解析JSON、下载、安装还要处理不同Android版本的安装权限一个不小心就掉坑里了。最近我在重构一个老项目时就遇到了这个问题。原有的更新模块耦合了后端接口逻辑代码臃肿且难以维护。我就在想能不能做一个极简、高内聚的更新下载模块它的输入只有一个最新的APK文件下载地址。剩下的所有事情——网络请求、下载管理、进度显示、版本兼容性安装——全部由这个模块内部消化掉。这个想法听起来简单但实现起来却有不少门道。它不仅仅是一个DownloadManager的简单封装更需要考虑国产ROM的兼容性、Android 7.0以上的文件共享FileProvider、下载过程中的通知栏适配、以及安装时的用户引导。今天我就把这个从需求分析到代码实现的完整过程拆解出来分享如何构建一个健壮的、只需要一个APK下载地址就能完成所有工作的更新下载功能。无论你是刚入门的新手还是想优化现有逻辑的老手相信都能从中找到可以直接“抄作业”的代码和避坑思路。2. 核心设计思路与架构选型2.1 为什么选择“仅APK地址”作为输入首先我们来聊聊为什么要把设计边界定得如此清晰。传统的更新流程通常是客户端请求一个版本检查接口服务端返回一个包含新版本号、更新日志、是否强制更新以及APK下载地址的JSON数据包。客户端解析后再根据下载地址去获取文件。这种做法的问题在于业务逻辑检查更新和下载逻辑获取文件高度耦合。一旦后端接口格式变动或者你想在另一个不需要检查更新、只需要下载APK的场景比如应用内资源包下载中复用下载逻辑就会非常麻烦。因此我的设计原则是单一职责与高内聚。这个模块只做一件事给定一个有效的、指向APK文件的网络地址将它安全、可靠地下载到本地并引导用户安装。至于这个地址从哪里来版本检查接口、扫码、推送消息模块不关心。这样做的好处显而易见可复用性极强任何需要下载APK的场景都可以直接调用。易于测试只需构造一个测试用的APK下载链接无需搭建完整的后端Mock环境。维护成本低模块内部逻辑自闭环外部变化对其影响小。2.2 技术方案对比与选型实现下载功能主要有三种技术路径HttpURLConnection/OkHttp自行管理、Android系统DownloadManager、以及第三方下载库如FileDownloader。我们来逐一分析。方案一使用OkHttp自行实现这是最灵活、控制粒度最细的方案。你可以自己处理断点续传、多线程下载、进度回调等。但实现一个健壮的下载器工作量不小需要处理网络状态变化、存储权限、文件读写异常等诸多细节。对于“版本更新”这个特定场景有点杀鸡用牛刀且容易引入不必要的复杂性。方案二使用Android系统DownloadManager这是系统提供的专门用于处理长时间HTTP下载的服务。它的最大优点是省心。系统会帮你管理下载队列、处理网络切换、并在通知栏显示进度。即使你的应用进程被杀死下载任务也会在后台继续。这对于用户点击“后台下载”后退出App的场景非常友好。缺点是定制性较弱比如下载过程中的UI提示样式受系统限制不同厂商的ROM对通知栏的定制也可能导致体验不一致。方案三使用第三方下载库像FileDownloader这样的库功能强大封装良好解决了方案一的复杂性问题。但引入第三方库会增加包体积和依赖风险并且库的更新维护也是一个考量点。我的选择基于DownloadManager的增强封装经过权衡我选择了方案二作为基础并进行增强封装。理由如下符合场景版本更新下载是一个典型的“发起后可能离开”的任务DownloadManager的后台持久化特性完美匹配。稳定性高系统服务经过多年迭代兼容性和稳定性有保障。减少工作量避免了重复造轮子让我们能更专注于下载完成后的安装逻辑这一核心难点。我们的增强点在于如何优雅地监听DownloadManager的下载完成广播如何处理Android N及以上版本的安装权限FileProvider如何构建一个清晰的回调接口给业务层使用接下来我们就进入核心实现环节。3. 核心实现细节与关键代码解析3.1 构建下载请求与监听下载状态首先我们需要使用DownloadManager来发起一个下载请求。核心类是DownloadManager.Request。// 使用Kotlin示例Java思路类似 fun startDownload(context: Context, apkUrl: String, fileName: String): Long { val downloadManager context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager // 1. 创建下载请求 val request DownloadManager.Request(Uri.parse(apkUrl)).apply { // 设置通知栏可见性下载中及完成后均可见 setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) // 设置下载文件保存的路径和文件名 // 注意从Android Q开始对应用私有目录的访问受限建议使用Download目录 setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) // 设置允许的网络类型移动网络和WIFI setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE or DownloadManager.Request.NETWORK_WIFI) // 设置允许漫游时下载默认不允许 setAllowedOverRoaming(false) // 设置标题和描述会在通知栏显示 setTitle(${context.getString(R.string.app_name)}更新包) setDescription(正在下载新版本请稍候...) // 重要设置MIME类型为APK setMimeType(application/vnd.android.package-archive) } // 2. 将请求加入下载队列并返回一个唯一的下载ID用于后续查询和操作 return downloadManager.enqueue(request) }这里有几个关键点文件存储路径使用setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)将APK保存在公共下载目录。这比放在应用私有目录更好因为安装器PackageInstaller需要能访问到这个文件。从Android 10开始对应用私有目录的访问限制更严格放在公共目录是更兼容的做法。MIME类型必须设置为application/vnd.android.package-archive这是APK文件的标准MIME类型。这会影响系统对这个文件行为的识别。下载IDenqueue方法返回一个Long型的ID这是管理这个下载任务的唯一凭证必须保存下来例如存入SharedPreferences因为后续的进度查询和完成监听都依赖它。接下来我们需要监听下载完成或失败的广播。这里不能使用简单的BroadcastReceiver动态注册因为用户可能在下载过程中退出App。我们采用在AndroidManifest.xml中静态注册一个BroadcastReceiver并监听DownloadManager.ACTION_DOWNLOAD_COMPLETE广播。receiver android:name.ApkDownloadReceiver android:exportedfalse intent-filter action android:nameandroid.intent.action.DOWNLOAD_COMPLETE / action android:nameandroid.intent.action.DOWNLOAD_NOTIFICATION_CLICKED / /intent-filter /receiverclass ApkDownloadReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { when (intent?.action) { DownloadManager.ACTION_DOWNLOAD_COMPLETE - { val downloadId intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) if (downloadId ! -1L) { // 根据保存的downloadId判断是否是我们的任务 val sp context.getSharedPreferences(download_prefs, Context.MODE_PRIVATE) val savedId sp.getLong(latest_download_id, -1) if (downloadId savedId) { // 下载完成触发安装流程 installApk(context, downloadId) } } } DownloadManager.ACTION_DOWNLOAD_NOTIFICATION_CLICKED - { // 用户点击了下载完成的通知可以打开下载管理器或我们的应用 val intentToApp Intent(context, MainActivity::class.java).apply { flags Intent.FLAG_ACTIVITY_NEW_TASK } context.startActivity(intentToApp) } } } }注意在Android 8.0及以上版本对静态注册的Receiver有更严格的限制。DownloadManager.ACTION_DOWNLOAD_COMPLETE是系统发出的受保护广播我们的应用在下载任务进行时是默认能接收到的。但为了更好的兼容性特别是处理通知点击事件建议在代码中动态注册和取消注册Receiver作为补充。3.2 安装APK的完整兼容性处理下载完成后最复杂的一步来了安装APK。从Android 7.0开始为了提升安全性禁止应用直接通过file://URI分享文件给其他应用如安装器必须使用FileProvider生成一个content://URI。这就是我们经常在Logcat里看到的FileUriExposedException异常的来源。第一步配置FileProvider在AndroidManifest.xml的application标签内添加provider 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这里的authorities通常约定为应用包名.fileprovider${applicationId}会被Gradle自动替换。第二步创建文件路径配置文件在res/xml/目录下创建file_paths.xml文件如果没有xml文件夹就新建一个。?xml version1.0 encodingutf-8? paths !-- 对应外部存储的Download目录 -- external-path namedownload pathDownload/ / !-- 也可以添加其他路径例如应用私有缓存目录 -- cache-path namecache path. / !-- 适配Android 10 的沙盒存储访问媒体文件 -- external-files-path nameexternal_files path. / /paths这个文件定义了FileProvider可以共享的文件目录。我们将公共下载目录Download/共享出去这样安装器就能通过我们提供的content://URI访问到下载好的APK文件。第三步实现兼容的安装方法现在我们可以编写一个兼容Android所有版本的安装方法了。fun installApk(context: Context, downloadId: Long) { val downloadManager context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val query DownloadManager.Query().setFilterById(downloadId) val cursor downloadManager.query(query) if (cursor.moveToFirst()) { val status cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) if (status DownloadManager.STATUS_SUCCESSFUL) { // 获取下载文件的本地URI val localUriString cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) val localUri Uri.parse(localUriString) val apkFile File(localUri.path ?: return) // 注意从Uri获取path可能不可靠尤其是content:// Uri // 更可靠的方式直接通过DownloadManager获取文件描述符或使用COLUMN_LOCAL_FILENAME已废弃 // 推荐使用我们已知的、保存到公共下载目录的文件路径来构造File对象 val fileName cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)) ?: update.apk val downloadsDir Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val apkFileReliable File(downloadsDir, fileName) if (apkFileReliable.exists()) { val intent Intent(Intent.ACTION_VIEW).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // 授予临时读取权限 val apkUri if (Build.VERSION.SDK_INT Build.VERSION_CODES.N) { // Android 7.0及以上使用FileProvider FileProvider.getUriForFile( context, ${context.packageName}.fileprovider, // 必须和Manifest中一致 apkFileReliable ) } else { // Android 7.0以下直接使用file:// Uri Uri.fromFile(apkFileReliable) } setDataAndType(apkUri, application/vnd.android.package-archive) } // 检查是否有可以处理安装Intent的应用 if (intent.resolveActivity(context.packageManager) ! null) { context.startActivity(intent) } else { Toast.makeText(context, 未找到可安装应用的程序, Toast.LENGTH_SHORT).show() } } else { Toast.makeText(context, 安装文件不存在, Toast.LENGTH_SHORT).show() } } else { // 处理下载失败的情况 val reason cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)) Toast.makeText(context, 下载失败错误码$reason, Toast.LENGTH_SHORT).show() } } cursor.close() }这段代码是安装逻辑的核心有几个极易踩坑的地方COLUMN_LOCAL_URI的陷阱在Android Q及以上版本DownloadManager返回的COLUMN_LOCAL_URI可能是content://格式的Uri直接解析其path可能无法得到有效的文件路径。因此更可靠的做法是使用我们下载时指定的、已知的公共目录路径来重新构造File对象。FileProvider的authoritiesFileProvider.getUriForFile()的第二个参数必须和AndroidManifest.xml中provider标签里定义的android:authorities完全一致否则会抛出IllegalArgumentException。权限授予Intent.FLAG_GRANT_READ_URI_PERMISSION这个Flag至关重要它临时授予了安装器应用读取我们提供的content://URI的权限。没有这个Flag安装器会因权限不足而崩溃。检查Intent是否可处理使用resolveActivity检查系统中有没有能处理安装APK的应用通常就是“软件包安装程序”。这是一个良好的编程习惯可以避免因系统环境异常导致的崩溃。4. 封装成高可用组件与回调设计4.1 构建一个清晰的下载管理器我们将上述分散的逻辑封装到一个单例或依赖注入管理的类中比如ApkUpdateManager。这个类对外提供简洁的接口。class ApkUpdateManager private constructor(private val context: Context) { companion object { Volatile private var instance: ApkUpdateManager? null fun getInstance(context: Context): ApkUpdateManager { return instance ?: synchronized(this) { instance ?: ApkUpdateManager(context.applicationContext).also { instance it } } } } interface DownloadListener { fun onDownloadStarted(downloadId: Long) fun onDownloadProgress(downloadId: Long, bytesDownloaded: Long, totalBytes: Long) fun onDownloadCompleted(downloadId: Long, fileUri: Uri?) fun onDownloadFailed(downloadId: Long, reason: Int) } private var currentDownloadId: Long -1 private var downloadListener: DownloadListener? null private val downloadManager by lazy { context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager } fun setDownloadListener(listener: DownloadListener) { this.downloadListener listener } fun downloadApk(apkUrl: String, fileName: String app_update_${System.currentTimeMillis()}.apk) { // ... 调用之前的startDownload方法 ... currentDownloadId startDownloadInternal(context, apkUrl, fileName) downloadListener?.onDownloadStarted(currentDownloadId) // 启动一个线程或协程定期查询进度可选因为DownloadManager的通知栏已有进度 startProgressQuery(currentDownloadId) } fun getDownloadStatus(downloadId: Long) { // 主动查询一次状态 queryDownloadStatus(downloadId) } // ... 内部实现方法 startDownloadInternal, startProgressQuery, queryDownloadStatus ... }4.2 实现进度查询可选但推荐DownloadManager的通知栏自带进度但如果我们想在应用内自己的UI上显示一个精美的进度条就需要主动轮询查询下载状态。我们可以通过一个Handler、Timer或者更现代的Coroutine来实现。private fun startProgressQuery(downloadId: Long) { // 使用协程示例 CoroutineScope(Dispatchers.IO).launch { var isDownloading true while (isDownloading) { delay(1000) // 每秒查询一次 val query DownloadManager.Query().setFilterById(downloadId) val cursor downloadManager.query(query) if (cursor.moveToFirst()) { val status cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) when (status) { DownloadManager.STATUS_RUNNING - { val bytesDownloaded cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) val totalBytes cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) withContext(Dispatchers.Main) { downloadListener?.onDownloadProgress(downloadId, bytesDownloaded, totalBytes) } } DownloadManager.STATUS_SUCCESSFUL - { isDownloading false val localUri cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) withContext(Dispatchers.Main) { downloadListener?.onDownloadCompleted(downloadId, Uri.parse(localUri)) } } DownloadManager.STATUS_FAILED - { isDownloading false val reason cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)) withContext(Dispatchers.Main) { downloadListener?.onDownloadFailed(downloadId, reason) } } DownloadManager.STATUS_PAUSED, DownloadManager.STATUS_PENDING - { // 处理暂停或等待状态 } } } cursor.close() } } }注意频繁查询DownloadManager会有性能开销建议根据UI更新的需要调整查询频率如每秒一次或每500毫秒一次。同时一定要在下载完成或失败后停止轮询并关闭Cursor避免内存泄漏。5. 避坑指南与进阶优化5.1 国产ROM深度适配与疑难杂症在实际测试中最大的挑战来自各厂商深度定制的Android系统如MIUI、EMUI、ColorOS等。它们可能会修改系统默认行为导致“意料之外”的问题。1. 后台下载权限与省电策略许多国产ROM有严格的省电策略会在应用切到后台后限制其网络活动这可能导致DownloadManager发起的下载被暂停。解决方案是引导用户将你的App加入后台运行白名单“电池优化”忽略列表。可以在下载前检查并弹出引导提示。fun checkBatteryOptimization(context: Context): Boolean { if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { val powerManager context.getSystemService(Context.POWER_SERVICE) as PowerManager return !powerManager.isIgnoringBatteryOptimizations(context.packageName) } return false } // 如果返回true说明应用未被忽略电池优化可以引导用户去设置2. 安装权限弹窗被拦截有些ROM的安全中心或管家类应用会拦截未知来源应用的安装弹窗导致用户看不到安装界面。处理方法是确保已经正确引导用户开启了“允许来自此来源的应用”开关针对Android 8.0以上。在调用安装Intent前可以尝试添加一个Intent.FLAG_ACTIVITY_CLEAR_TOP或Intent.FLAG_ACTIVITY_SINGLE_TOP的Flag有时能绕过一些拦截。最根本的是在下载完成后除了自动触发安装在应用内提供一个醒目的按钮如“立即安装”点击后再次执行安装逻辑。因为用户主动点击按钮的行为更容易被系统识别为用户意图从而减少被拦截的概率。3. 文件路径访问问题在Android 11及以上版本即使使用了FileProvider对公共目录的访问也可能受限。更稳妥的做法是将APK下载到应用专属的外部存储目录Context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)这个目录不需要存储权限即可访问并且通过FileProvider共享给安装器也是可行的。你需要相应地修改file_paths.xml和下载请求的目标路径。5.2 安全与用户体验增强1. APK文件校验从网络下载的APK文件可能存在被篡改的风险。为了确保安装包的安全性可以在下载完成后计算文件的哈希值如SHA-256并与服务端预先提供的哈希值进行比对。只有校验通过才执行安装。fun verifyApk(file: File, expectedSha256: String): Boolean { val digest MessageDigest.getInstance(SHA-256) file.inputStream().use { inputStream - val buffer ByteArray(8192) var bytesRead: Int while (inputStream.read(buffer).also { bytesRead it } ! -1) { digest.update(buffer, 0, bytesRead) } } val actualSha256 digest.digest().joinToString() { %02x.format(it) } return actualSha256.equals(expectedSha256, ignoreCase true) }2. 处理下载任务冲突如果用户连续点击更新按钮可能会发起多个下载任务。我们可以在downloadApk方法开始时检查是否已存在一个未完成的下载任务通过查询DownloadManager中状态为STATUS_RUNNING或STATUS_PENDING且由本应用发起的任务。如果存在可以提示用户“已有任务在进行中”或者取消旧任务开始新任务。3. 提供手动安装入口如前所述在下载完成的通知栏点击或者应用内提供一个常驻的“安装”按钮可以极大提升在复杂系统环境下的安装成功率。这个按钮的点击事件就是调用我们封装好的installApk方法。5.3 网络热词中相关问题的引申思考在提供的网络热词中出现了如content://com.baidu.searchbox.fileprovider/...和content://com.tencent.wework.fileprovider/...这样的字符串。这其实是其他应用如百度搜索框、企业微信使用FileProvider共享文件时生成的URI。这从侧面印证了FileProvider机制在跨应用文件共享中的普遍性。理解这个机制不仅对我们实现APK安装有用对于任何需要应用间分享文件如图片、文档的功能都至关重要。另一个热词android插件化江湖:从droidplugin到shadow的技术演进则指向了更高级的动态加载技术。我们的“下载更新”是替换整个APK而插件化则是动态加载并运行APK中的部分模块插件无需重新安装主App。虽然技术路径不同但底层都涉及APK文件的获取、校验和安全加载在文件管理和安全校验方面有共通之处。6. 完整集成示例与调用方式最后我们来看一个在Activity或Fragment中集成的完整示例。class MainActivity : AppCompatActivity(), ApkUpdateManager.DownloadListener { private lateinit var apkUpdateManager: ApkUpdateManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) apkUpdateManager ApkUpdateManager.getInstance(applicationContext) apkUpdateManager.setDownloadListener(this) val updateButton: Button findViewById(R.id.btn_check_update) updateButton.setOnClickListener { // 假设从服务器获取到了最新的APK地址 val latestApkUrl https://your-cdn.com/path/to/app-v2.0.0.apk // 开始下载 apkUpdateManager.downloadApk(latestApkUrl, MyApp_Update.apk) } val installButton: Button findViewById(R.id.btn_manual_install) installButton.setOnClickListener { // 手动触发安装流程尝试安装已下载的文件 val sp getSharedPreferences(download_prefs, MODE_PRIVATE) val savedId sp.getLong(latest_download_id, -1) if (savedId ! -1L) { installApk(this, savedId) // 调用之前定义的installApk函数 } else { Toast.makeText(this, 未找到已下载的安装包, Toast.LENGTH_SHORT).show() } } } override fun onDownloadStarted(downloadId: Long) { runOnUiThread { Toast.makeText(this, 开始下载更新包..., Toast.LENGTH_SHORT).show() // 可以在这里显示一个自定义的进度条对话框 } } override fun onDownloadProgress(downloadId: Long, bytesDownloaded: Long, totalBytes: Long) { runOnUiThread { val progress (bytesDownloaded * 100 / totalBytes).toInt() // 更新自定义进度条对话框 // progressDialog.setProgress(progress) // progressDialog.setMessage(已下载 $progress%) } } override fun onDownloadCompleted(downloadId: Long, fileUri: Uri?) { runOnUiThread { Toast.makeText(this, 下载完成准备安装, Toast.LENGTH_SHORT).show() // 关闭进度条对话框 // progressDialog.dismiss() // 自动触发安装 installApk(this, downloadId) // 同时显示手动安装按钮 // findViewByIdButton(R.id.btn_manual_install).visibility View.VISIBLE } } override fun onDownloadFailed(downloadId: Long, reason: Int) { runOnUiThread { // 关闭进度条对话框 // progressDialog.dismiss() val reasonText when (reason) { DownloadManager.ERROR_UNKNOWN - 未知错误 DownloadManager.ERROR_FILE_ERROR - 文件错误 DownloadManager.ERROR_UNHANDLED_HTTP_CODE - HTTP错误 DownloadManager.ERROR_HTTP_DATA_ERROR - HTTP数据错误 DownloadManager.ERROR_INSUFFICIENT_SPACE - 存储空间不足 DownloadManager.ERROR_DEVICE_NOT_FOUND - 存储设备未找到 DownloadManager.ERROR_CANNOT_RESUME - 无法断点续传 else - 错误码$reason } Toast.makeText(this, 下载失败$reasonText, Toast.LENGTH_LONG).show() } } // 记得在合适的生命周期如onDestroy取消监听避免内存泄漏 override fun onDestroy() { super.onDestroy() apkUpdateManager.setDownloadListener(null) } }这个示例展示了从触发下载到监听回调再到处理安装的完整闭环。将复杂的系统交互封装在ApkUpdateManager内部业务层代码变得非常清晰和易于维护。我个人在实际项目中的体会是把“下载”和“安装”这两个环节彻底解耦是关键。下载模块只负责把文件弄到本地并提供一个绝对路径或Uri安装模块只认这个路径/Uri并处理系统兼容性问题。这样即使未来Google在Android 15上又推出了新的安装限制比如PackageInstallerAPI的改动我们也只需要修改安装模块而下载逻辑可以保持不变。这种设计在面对快速变化的Android生态时能提供更好的抗风险能力。最后一个小技巧在测试时可以把APK文件放到本地HTTP服务器比如用Python的http.server模块来模拟下载地址能极大提升开发和调试效率。
返回列表