Android 版本控制 项目实战经验

258 阅读10分钟

一、概述

本篇主要阐述了实战任务 Android版本控制 中涉及到的一些策略方案和想法,包括:序列化,多线程,状态机,设计模式,监听等。

二、需求分析阶段

需求看起来很简单,简单概括起来就是:每日首次启动检查是否需要更新,更新又分为静默更新非静默更新强制更新非强制更新

拿到个任务我觉得首先需要分析的是,怎么实现这个需求,也就是,你想怎么设计?

当然实现需要做的就是对需求进行拆分,划分职责,使得每个类都有自己要完成的独立的任务,我要实现的无非就是围绕着这两个主题:

有了初步的设计思路之后,细化需求,确认需要用到的技术有哪些?明确基本的实现策略。以强制更新为例,思考以下几个问题:

  1. 怎么实现强制更新?

可以通过弹出不可关闭弹窗,禁用用户的一切操作。

  1. 下载,安装怎么实现?
  2. 弹窗如何去控制?

多个弹窗不能一次性全部弹出,他们的出现其实也是有顺序的。

  1. 用户杀后台,弹窗重新弹出的逻辑要怎么实现?

这里肯定涉及到序列化操作,具体采用哪个方案再说。

三、总体设计阶段

需要一个总控制类VersionManager,协调控制其他的功能类。

我这里将需求拆分为六个类分别去实现相应的功能。

  • DownLoadNotificationManager 控制下载通知
  • NetworkReceiver 监听设备的网络变化
  • DialogQueueManager 弹窗管理器
  • DataStoreManager 序列化策略的封装
  • DownloadManager 下载部分的管理
  • InstallManager 安装部分的管理

四、详细设计阶段

*Task 任务

作为多个 Manager 处理的对象,设计一个贯穿始终的 Task 任务,以Manager 驱动 Task 的形式完成设计。

open class DownloadTask(val taskId: Long, val downloadUrl: String, var isSilentDownload: Boolean) {
    //初始为暂停状态
    var state: Int = DownloadManager.STATE_PAUSED
}
class VersionUpdateTask(
    taskId: Long,
    downloadUrl: String,
    isSilentDownload: Boolean,
    val latestVersion: String,
    val mandatoryUpdate: Int
) :
    DownloadTask(taskId, downloadUrl, isSilentDownload)

1.DownloadManager

职责:拿到 Task 任务,下载对应的 Task,保存为 apk 文件。

考虑到资源共享与管理,设计为单例类。总体的思路是通过 okhttp3 连接网路,通过 io 流从缓冲区读取并写入文件。

为了更好的管理下载的状态,我这里这里采用了状态机这一方案。

class DownloadManager private constructor(context: Context) {
	companion object {
		@Volatile
		private var instance: DownloadManager? = null

		fun getInstance(context: Context): DownloadManager =
		instance ?: synchronized(this) {
			instance ?: DownloadManager(context.applicationContext).also {
				instance = it
			}
		}

		// 下载缓冲区大小和更新频率
		private const val BUFFER_SIZE = 8 * 1024 // 8KB
		private const val UP_DATA_FREQUENCY = 1000L // 1 second

		//下载的状态,每个Task各自持有
		const val STATE_DOWNLOADING = 0
		const val STATE_PAUSED = 1
		const val STATE_COMPLETED = 2
	}
}

暴露出一个挂起函数handelDownloadTask()VersionManager 调用,开始下载。同时确保下载的操作是在 IO 线程中完成的。

下载到一半网络断开了怎么办?下载到一半用户关闭了 APP 怎么办?这里就需要用到断点续传的技术,这样可以实现在已经下载好了的基础上继续下载的逻辑,而不是每次都重新开始下载。

断点续传其实就是读取当前文件的大小(也就是已经下载好的大小),然后告诉服务器我现在下载到哪了,就能接着当前的进度开始继续下载。

当拿到任务的时候,先检查任务的状态,STATE_DOWNLOADINGSTATE_PAUSEDSTATE_COMPLETED判断当前是哪个状态,并进行状态的变更,这里设置下载完成这一状态是因为,当文件下载完成时再进行断点续传会出现错误。

suspend fun handelDownloadTask(task: VersionUpdateTask, context: Context) {

	withContext(Dispatchers.IO) {

		try {
			val downloadDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
			?: throw IOException("无法获取下载目录")

			Log.d("DownloadManager", "APK 文件路径: ${downloadDir.absolutePath}")

			//尝试下载任务
			val client = OkHttpClient().newBuilder()
				.hostnameVerifier(AllowAllHostnameVerifier())
				.build()

			if (!downloadDir.exists()) downloadDir.mkdirs()

			val apkFile = File(downloadDir, "update_${task.downloadUrl.hashCode()}.apk")

			// 先检查任务状态
			when (task.state) {
				STATE_PAUSED -> {
					
				}

				STATE_DOWNLOADING -> {
					
				}

				STATE_COMPLETED -> {

				}
			}

			// 已下载字节数
			val downloadedBytes = apkFile.length()
			Log.d(
				"DownloadManager", "已经下载好的大小:" + formatFileSize(
					downloadedBytes
				)
			)

			val request =
			Request.Builder().url(task.downloadUrl)
				.header("Range", "bytes=$downloadedBytes-")
				.build()
			client.newCall(request).execute().use { response ->

				val contentLength = response.body?.contentLength() ?: -1

				// 正式开始写入文件(断点续传)
				when (response.code) {
					// 服务器支持断点续传 (206 Partial Content)
					HttpURLConnection.HTTP_PARTIAL -> {
						Log.d("DownloadManager", "支持断点续传")
						
					}
					// 服务器不支持断点续传 (200 OK)
					HttpURLConnection.HTTP_OK -> {
						Log.d("DownloadManager", "不支持断点续传")
						
					}

					else -> {
						Log.d("DownloadManager", "未知响应码: ${response.code}")
						
						return@withContext
					}
				}

				task.state = STATE_COMPLETED
				dataStoreManager.updateState(context, task.taskId, STATE_COMPLETED)

				//下载完成调用
				withContext(Dispatchers.Main) {
					Log.d("DownloadManager", "下载完成,准备安装 APK")
					if (task.isSilentDownload) {
						//通知安装
						DialogQueueManager.instance.showIfInstallDialog(apkFile)
					} else {
						//直接安装APK
						downLoadNotificationManager.sendDownloadCompleteNotification(
							context
						)
					}
				}
				versionManager.tryInstallApk(apkFile,context)
			}
		} catch (e: SocketException) {
			withContext(Dispatchers.Main) {
				Log.d("DownloadManager", "下载暂停")
				Toast.makeText(context, "下载暂停", Toast.LENGTH_SHORT).show()
				versionManager.isHandling = false
				task.state = STATE_PAUSED
				dataStoreManager.updateState(context, task.taskId, STATE_PAUSED)
				if (!task.isSilentDownload) {
					downLoadNotificationManager.sendDownloadPauseNotification(context)
				}
			}
		} catch (e: IOException) {
			withContext(Dispatchers.Main) {
				Log.e("DownloadManager", "下载失败", e)
				Toast.makeText(context, "下载失败: ${e.message}", Toast.LENGTH_SHORT).show()
				versionManager.isHandling = false
				versionManager.clearTasks(context)
				if (!task.isSilentDownload) {
					downLoadNotificationManager.sendDownloadFailedNotification(
						context,
						e.message
					)
				}
			}
		}
	}
}

其余的writeFile()``copyStreamWithProgress()等方法这里就不放出来了,在文末有完整的源码地址。

2.NetworkReceiver

职责:监听网络状态的变化,并进行对应的回调操作。

回调接口NetworkStateCallback

interface NetworkStateCallback {
    fun onNetworkChanged(
        networkType: NetworkType,
    )
}

网路状态枚举类NetworkType

enum class NetworkType {
    WIFI,          // WiFi 网络
    CELLULAR,      // 蜂窝移动网络(4G/5G)
    OTHER,         // 其他类型(以太网、VPN 等)
    DISCONNECTED,  // 无网络连接
    UNKNOWN        // 初始未知状态
}

实现逻辑:

监听的逻辑 Android 源码中有很多,这里模仿 LifeCycle的生命周期的回调方法,用 list 去存监听者,在合适的时机遍历通知观察者。

实现的是ConnectivityManager.NetworkCallback()这一接口去注册网络状态变化的回调,这个监听不想普通的点击事件监听,他是生命周期是很长久的,显而易见的很浪费性能,所以一定要暴露出开关方法让监听有迹可循。

同时需要一个注册的方法addListener()

class NetworkReceiver private constructor(context: Context): ConnectivityManager.NetworkCallback() {
    companion object{
        var instance: NetworkReceiver? = null

        fun getInstance(context: Context): NetworkReceiver {
            return instance ?: synchronized(this) {
                instance ?: NetworkReceiver(context).also {
                    instance = it
                }
            }
        }
    }

    // 网络类型检测回调接口
    private val callbacks = mutableListOf<NetworkStateCallback>()

    private val connectivityManager =
    context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    // 当前网络状态跟踪(默认 UNKNOWN)
    private var currentNetworkType: NetworkType = NetworkType.UNKNOWN
    private var isMonitoring = false

    // 注册监听器
    fun addListener(callback: NetworkStateCallback) {
        
    }

    // 开始监听网络变化
    fun startMonitoring(){
        
    }


    // 停止监听
    fun stopMonitoring(){
        
    }



    override fun onAvailable(network: Network) = updateNetworkState(network)

    /**
     * 当网络永久丢失时触发(非瞬时断开)
     * 当onLost触发之后 connectivityManager.activeNetwork 和 network 有以下特点
     * 网络已断开,系统可能已清除其能力信息,此时通过 network 获取的 caps 为 null
     * 但是也有可能未及时清除,可能返回断开前的最后能力信息(但此时网络已不可用)
     * 但是这里delay(100)测试效果 就是清除后的数据
     */
    override fun onLost(network: Network) {
        CoroutineScope(Dispatchers.Main).launch {
            delay(100)
            updateNetworkState(network)
        }
    }

    override fun onCapabilitiesChanged(
        network: Network,
        networkCapabilities: NetworkCapabilities
    ) = updateNetworkState(network)

    private fun updateNetworkState(network: Network?) {
        
    }
}

3.DialogQueueManager

职责:负责全局的弹窗控制

上面也分析过了,如果有多个弹窗不可能一股脑的全部弹出来吧,所以这里采用队列来管理,让他们能够协调有序得出现。

还有一个问题就是 dialog 需要 activity 的 context ,众所周知当在不恰当的时候弹出弹窗会引起程序崩溃,作为全局的一个单例,怎么知道当前所在的 activity 呢,实现ActivityLifecycleCallbacks并暴露个 init 方法注册到Application中。为了不造成内存泄漏,这里还要采用弱引用的方式去持有。

通过setOnDismissListener监听当前 dialog 是否关闭,当关闭之后再去显示下一个 dialog

class DialogQueueManager private constructor(): ActivityLifecycleCallbacks{

	companion object{
		val instance by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
			DialogQueueManager()
		}
	}

	private var pageContext: WeakReference<Activity>? = null
	private val dialogQueue: ArrayDeque<() -> AlertDialog?> = ArrayDeque()
	private var isDialogShowing = false
	private var currentDialog: AlertDialog? = null

	fun init(context: Application){
		context.registerActivityLifecycleCallbacks(this)
	}

	private fun addToQueue(dialogCreator: (Activity) -> AlertDialog?) {
		val context = pageContext?.get() ?: run {
			Log.w("DialogManager", "Activity context is null")
			return
		}

		dialogQueue.add {
			val dialog = dialogCreator(context)?.apply {
				setOnDismissListener {
					isDialogShowing = false
					currentDialog = null
					showNextDialog()
				}
			}
			currentDialog = dialog
			dialog
		}

		if (!isDialogShowing) {
			showNextDialog()
		}
	}

	private fun showNextDialog() {
		if (isDialogShowing) return

		val context = pageContext?.get() ?: run {
			clearQueue()
			return
		}

		if (context.isFinishing || context.isDestroyed) {
			clearQueue()
			return
		}

		while (dialogQueue.isNotEmpty()) {
			val dialogBuilder = dialogQueue.removeFirst()
			val dialog = dialogBuilder.invoke()

			if (dialog != null && !context.isFinishing && !context.isDestroyed) {
				isDialogShowing = true
				dialog.show()
				return
			}
		}
	}

	private fun clearQueue() {
		dialogQueue.clear()
		currentDialog?.dismiss()
		currentDialog = null
		isDialogShowing = false
	}

}

4.DownLoadNotificationManager

职责:主要是为了显示下载情况下让用户能都感受到当前正在执行下载任务,静默下载的情况下用不到。

下面只展示一部分:

/**
 * 通知控制类 - 负责管理版本更新过程中的各种通知状态
 */
class DownLoadNotificationManager private constructor() {
	// 通知管理器实例
	private var notificationManager: NotificationManager? = null

	companion object {
		// 单例实例
		val instance by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
			DownLoadNotificationManager()
		}
		// 通知渠道配置常量
		private const val CHANNEL_ID = "download_channel"         // 通知渠道ID
		private const val NOTIFICATION_ID = 1                    // 统一通知ID
	}

	/**
     * 创建通知渠道
     * @param context 上下文对象
     */
	fun createNotificationChannel(context: Context) {
		notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager

		// 如果渠道不存在则创建
		if (notificationManager?.getNotificationChannel(CHANNEL_ID) == null) {
			val channel = NotificationChannel(
				CHANNEL_ID,
				"应用下载",  // 渠道名称
				NotificationManager.IMPORTANCE_LOW  // 低重要性(不弹出通知,显示在状态栏)
			).apply {
				description = "用于显示版本更新下载进度"  // 渠道描述
			}
			notificationManager?.createNotificationChannel(channel)
		}
	}

	fun destroyNotificationChannel() {
		notificationManager?.deleteNotificationChannel(CHANNEL_ID)
	}

	/**
     * 更新下载进度通知
     * @param context 上下文对象
     * @param downloadedBytes 已下载字节数
     * @param totalBytes 总字节数
     * @param speedBps 当前下载速度(字节/秒)
     */
	fun updateProgressNotification(
		context: Context,
		downloadedBytes: Long,
		totalBytes: Long,
		speedBps: Long
	) {

	}

	private fun formatFileSize(bytes: Long): String {
		if (bytes <= 0) return "0 B"
		val units = arrayOf("B", "KB", "MB", "GB")
		var size = bytes.toDouble()
		var unitIndex = 0

		while (size >= 1024 && unitIndex < units.lastIndex) {
			size /= 1024
			unitIndex++
		}
		return "%.1f %s".format(size, units[unitIndex])
	}
}

5.InstallManager

职责:获取下载好的文件和当前的任务状态,执行安装的任务。

这一套其实是通过 Task 任务来沟通处理的,所以应该是要监听安装完成和安装失败事件的,但是在安装成功的情况下会自动结束当前 app 的或者是直接跳转到新版本中去的,在 app 层面时候永远不可能监听到的,所以回过来在处理每个 Task 之前对比一下当前 Task 的版本和动态获取到的版本信息。可以完美解决安装完成之后需要执行的逻辑。至于安装失败的情况,超时?用户取消安装?安装包无法解析的问题是必须要处理的。这里其实主要是 安装会话回调 的处理。

我这里用的是suspendCoroutine可以随时通知到外部协程继续执行和BroadcastReceiver

这里代码就不贴了。

6.DataStoreManager

职责:持久化 Task 实例,也就是要实现你不处理完这个任务,这个任务永远回留在你设备上,达到强制更新的效果。

我这里因为单个 Task 数据量不大,数量也才几个,所以采用了 datastore。datastore 对 flow 的支持很好,可以看一下我之前的文章:彻底搞清Flow+MVVM+Retrofit+OKHTTP框架

val versionUpdateTasksFlow: Flow<List<VersionUpdateTask>> =
	// 从 DataStore 获取原始 Preferences 数据流
	context.versionUpdateTaskDataStore.data
// 异常捕获处理(处理上游数据流可能抛出的异常)
.catch { ex ->
	// 如果是 IO 异常(如文件读取失败)
	if (ex is IOException) {
		// 发射空 Preferences 对象保持流程继续
		emit(emptyPreferences())
	} else {
		// 非 IO 异常重新抛出(如类型转换错误,由上层统一处理)
		throw ex
	}
}
// 将 Preferences 转换为业务对象列表
	.map { prefs ->
		try {
			// 尝试从 Preferences 获取存储的 JSON 字符串
			prefs[versionUpdateTasksKey]?.let { json ->
				// 使用 Gson 进行反序列化时需要处理泛型擦除问题
				val type = object : TypeToken<List<VersionUpdateTask>>() {}.type

				// 执行反序列化操作(可能抛出 JsonSyntaxException)
				gson.fromJson<List<VersionUpdateTask>>(json, type)
				// 处理空指针保护(当 json 是 "null" 时返回空列表)
				?: emptyList()
			}
			// 如果键值不存在(首次使用或数据被清空),返回空列表
			?: emptyList()
		} catch (e: JsonSyntaxException) {
			// JSON 格式错误处理(如手动修改存储文件导致格式损坏)
			emptyList() // 安全降级,返回空列表避免崩溃
			// 建议在此添加日志记录:Log.e("DataStore", "JSON 解析失败", e)
		}
	}

总结

其实这里还设计到多线程的一些问题,不能够有多个任务同时被处理吧,这里就不细讲了。本人起先写的代码还是很乱糟糟的,很没有层次结构,感谢阿里巴巴学长的执导:小王学长

结尾

欢迎光临我的 GitHub:CoreCodeLibrary
里面能找到完整的代码!
喜欢的话记得给 star 哦!
谢谢你们的点赞和和关注啦!

若有收获,就点个赞吧