Android Backup功能之全面实战(2)

本文涉及的产品
阿里云百炼推荐规格 ADB PostgreSQL,4核16GB 100GB 1个月
日志服务 SLS,月写入数据量 50GB 1个月
简介: Android Backup功能之全面实战(2)

ⅴ.进阶定制之限制备份来源

与中国市场上大都售卖无锁版设备不同,海外售卖的不少设备是绑定运营商的。而不同运营商上即便同一个应用,它们预设的数据可能都不同。这时候我们可能需要对备份数据的来源做出限制。

简言之A设备上面备份数据限制恢复到B设备。

20200308234636925.jpg如何实现?

因为自动备份模式下不会将数据的appVersionCode传回来,所以判断应用版本的办法行不通。而且有的时候应用版本是一致的,只是运营商不一致。


所以需要我们自己实现,大家可以自行思考。先说我之前想到的几种方案。


备份的时候将设备的名称埋入SP文件,恢复的时候检查SP文件里的值

备份的时候将设备的名称埋入新的File文件,恢复的时候检查File文件的值

这俩方案的缺陷:

方案1的缺点在于备份的逻辑会在原有的文件里增加值,会影响现有的逻辑。


方案2增加了新文件,避免对现有的逻辑造成影响,对方案1有所改善。但它和方案1都存在一个潜在的问题。


问题在于无法保证这个新文件首先被恢复到,也就无保证在恢复执行的一开始就知道本次恢复是否需要。


假使恢复进行到了一半,轮到标记新文件的时候才发现本次恢复需要丢弃,那么将会导致数据错乱。因为系统没有提供Roll back已恢复数据的API,如果我们自己也没做好保存和回退旧的文件处理的话,最后必然发生部分文件已恢复部分没恢复的不一致问题。


要理解这个问题就要搞清楚恢复操作针对文件的执行顺序。


自动备份模式在恢复的时候会逐个调用onRestoreFile(),将各个目录下备份的文件回调过来。目录之间的顺序和备份时候的顺序一致,如下备份的代码可以看出来:从根目录的Data开始,接着File目录开始,然后DB和SP文件。

public abstract class BackupAgent extends ContextWrapper {
    ...
    public void onFullBackup(FullBackupDataOutput data) throws IOException {
        ...
        // Root dir first.
        applyXmlFiltersAndDoFullBackupForDomain(
                packageName, FullBackup.ROOT_TREE_TOKEN, manifestIncludeMap,
                manifestExcludeSet, traversalExcludeSet, data);
        // Data dir next.
        traversalExcludeSet.remove(filesDir);
        // Database directory.
        traversalExcludeSet.remove(databaseDir);
        // SharedPrefs.
        traversalExcludeSet.remove(sharedPrefsDir);
    }
}

文件内的顺序则通过File#list()获取,而这个API是无法保证得到的文件列表都按照abcd的字母排序。所以在File目录下放标记文件不能保证它首先被恢复到。即便放一个a开头的标记文件也不能完全保证。


★推荐方案★

一般的App鲜少在根目录存放数据,而根目录最先被恢复到。所以我推荐的方案是这样的。


备份的时候将设备的名称埋入根目录的特定文件,恢复的时候检查该File文件,在恢复的初期就决定本次恢复是否需要。为了不影响恢复之后的正常使用,最后还要删除这个标记文件。


废话不多说,看下代码。


Backup里放入标记文件。

class MyBackupAgent : BackupAgentHelper() {
    ...
    override fun onFullBackup(data: FullBackupDataOutput?) {
        // ★ 在备份执行前先将标记文件写入Data目录
        // Make backup source file before full backup invoke.
        writeBackupSourceToFile()
        super.onFullBackup(data)
    }
    private fun writeBackupSourceToFile() {
        val sourceFile = File(dataDir.absolutePath + File.separator
                + Constants.BACKUP_SOURCE_FILE_PREFIX + Build.MODEL)
        if (!sourceFile.exists()) {
            sourceFile.createNewFile()
        }
    }
    ...
}
  • Restore检查标记文件。
class MyBackupAgent : BackupAgentHelper() {
    private var needSkipRestore = false
    ...
    override fun onRestoreFile(
            data: ParcelFileDescriptor?,
            size: Long,
            destination: File?,
            type: Int,
            mode: Long,
            mtime: Long
    ) {
        if (!needSkipRestore) {
            val sourceDevice = readBackupSourceFromFile(destination)
            // ★ 备份源设备名和当前名不一致的时候标记需要跳过
            // Mark need skip restore if source got and not match current device.
            if (!TextUtils.isEmpty(sourceDevice) && !sourceDevice.equals(Build.MODEL)) {
                needSkipRestore = true 
            }
        }
        if (!needSkipRestore) {
            // Invoke restore if skip flag set.
            super.onRestoreFile(data, size, destination, type, mode, mtime)
        } else {
            // ★ 跳过备份但一定要消费stream防止恢复的进程阻塞
            // Consume data to keep restore stream go.
            consumeData(data!!, size, type, mode, mtime, null) 
        }
    }
    ...
    private fun readBackupSourceFromFile(file: File?): String {
        if (file == null) return ""
        var decodeDeviceSource = ""
        // Got data file with backup source mark.
        if (file.name.startsWith(Constants.BACKUP_SOURCE_FILE_PREFIX)) {
            decodeDeviceSource = file.name.replace(Constants.BACKUP_SOURCE_FILE_PREFIX, "")
        }
        return decodeDeviceSource
    }
    @Throws(IOException::class)
    fun consumeData(data: ParcelFileDescriptor,
                    size: Long, type: Int, mode: Long, mtime: Long, outFile: File?) {
        ...
    }
}
  • 无论是Backup还是Restore都要将标记文件移除。
class MyBackupAgent : BackupAgentHelper() {
    ...
    override fun onDestroy() {
        super.onDestroy()
        // 移除标记文件
        // Ensure temp source file is removed after backup or restore finished.
        ensureBackupSourceFileRemoved()
    }
    private fun ensureBackupSourceFileRemoved() {
        val sourceFile = File(dataDir.absolutePath + File.separator
                + Constants.BACKUP_SOURCE_FILE_PREFIX + Build.MODEL)
        if (sourceFile.exists()) {
            val result = sourceFile.delete()
        }
    }
}

接下里验证代码能否拦截不同设备的备份文件。先在小米手机里备份文件,然后到Pixel模拟器里恢复这个数据。

  • 在小米手机里备份
>adb -s c7a1a50c7d27 backup -f auto-backup-cus-xiaomi.ab -apk com.ellison.backupdemo
>adb -s c7a1a50c7d27 logcat -s BackupManagerService -s BackupRestoreAgent
BackupManagerService: --- Performing full backup for package com.ellison.backupdemo ---
BackupRestoreAgent: onCreate()
BackupManagerService: agentConnected pkg=com.ellison.backupdemo agent=android.os.BinderProxy@5e68506
BackupManagerService: got agent android.app.IBackupAgent$Stub$Proxy@852a7c7
BackupManagerService: Calling doFullBackup() on com.ellison.backupdemo
BackupRestoreAgent: onFullBackup()
//  ★标记文件里写入了小米的设备名称并备份了
BackupRestoreAgent: writeBackupSourceToFile() sourceFile:/data/user/0/com.ellison.backupdemo/backup-source-Redmi 6A create:true ★
BackupRestoreAgent: onDestroy()
BackupManagerService: Adb backup processing complete.
BackupRestoreAgent: ensureBackupSourceFileRemoved() sourceFile:/data/user/0/com.ellison.backupdemo/backup-source-Redmi 6A delete:true ★
BackupManagerService: Full backup pass complete.
  • 往Pixel手机里恢复,可以看到Pixel的日志里显示跳过了恢复
>adb -s emulator-5554 restore auto-backup-cus-xiaomi.ab
>adb -s emulator-5554 logcat -s BackupManagerService -s BackupRestoreAgent
BackupManagerService: --- Performing full-dataset restore ---
...
BackupRestoreAgent: onRestoreFile() destination:/data/data/com.ellison.backupdemo/backup-source-Redmi 6A type:1  mode:384  mtime:1619355877 currentDevice:sdk_gphone_x86_arm needSkipRestore:false
BackupRestoreAgent: readBackupSourceFromFile() file:/data/data/com.ellison.backupdemo/backup-source-Redmi 6A
BackupRestoreAgent: readBackupSourceFromFile() source:Redmi 6A
BackupRestoreAgent: onRestoreFile() sourceDevice:Redmi 6A
// ★从备份数据里读取到了小米的设备名,不同于Pixel模拟器的名称,设定了跳过恢复的flag
BackupRestoreAgent: onRestoreFile() destination:/data/data/com.ellison.backupdemo/Post.jpg type:1  mode:384  mtime:1619355781 currentDevice:sdk_gphone_x86_arm needSkipRestore:true
BackupRestoreAgent: onRestoreFile() skip restore and consume ★
...
BackupRestoreAgent: onRestoreFinished()
BackupManagerService: [UserID:0] adb restore processing complete.
BackupRestoreAgent: onDestroy()
BackupManagerService: Full restore pass complete.

Pixel模拟器上重新打开App之后确实没有任何数据。

20200308234636925.jpg

当然如果App确实有在根目录下存放数据,那么建议你仍采用这个方案。


只不过需要给这个特定文件加一个a的前缀,以保证它大多数情况下会被先恢复到。当然为了防止极低的概率下它没有首先被恢复,开发者还需自行加上一个Data目录下文件的暂存和回退处理,以防万一。


更高的定制需求

如果发现备份的设备名称不一致的时候,客户的需求并不是丢弃恢复,而是让我们将运营商之间的diff merge进来呢?

这里提供一个思路。在上述方案的基础之上改下就行了。


比如恢复的一开始通过标记的文件发现备份的不一致,丢弃恢复的同时将待恢复的文件都改个别名暂存到本地。应用再次打开的时候读取暂存的数据和当前数据做对比,然后将diff merge进来。


如果不是限制恢复而是怕恢复的数据被别人看到,需要加个验证保护,怎么做?

譬如在恢复数据结束之后存一个需要验证账号的Flag。当App打开的时候发现Flag的存在会强制验证账户,输入验证码等。

ⅵ.BackupAgent和配置规则的混用

BackupAgent和XML配置并不冲突,在backup逻辑里还可以获取配置的设备条件。比如在onFullBackup()里可以利用FullBackupDataOutput的getTransportFlags()来取得相应的Flag来执行相应的逻辑。


FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED 对应着设备加密条件

FLAG_DEVICE_TO_DEVICE_TRANSFER 对应D2D备份场景条件

class MyBackupAgent: BackupAgentHelper() {
    ...
    override fun onFullBackup(data: FullBackupDataOutput?) {
        Log.d(Constants.TAG_BACKUP, "onFullBackup()")
        super.onFullBackup(data)
        if (data != null) {
            if ((data.transportFlags and FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) != 0) {
                Log.d(Constants.TAG_BACKUP, "onFullBackup() CLIENT ENCRYPTION NEED")
            }
        }
    }
}

Ⅳ. 键值对备份

键值对备份支持的空间小,而且针对File类型的Backup实现非线程安全,同时需要自行考虑DB这种大空间文件的备份处理,并不推荐使用。

但本着学习的目的还是要了解一下。

ⅰ. 基本定制

使用这个模式需额外指定BackupAgent并实现其细节。

<manifest ... >
    <application android:allowBackup="true"
                 android:backupAgent=".MyBackupAgent" ... >
        <!-- 为兼容旧版本设备最好加上api_key的meta-data -->
        <meta-data android:name="com.google.android.backup.api_key"
            android:value="unused" />
    </application>
</manifest>

BackupAgent的实现在于告诉BMS每个类型的文件采用什么Key备份和恢复。可以选择高度定制的复杂办法去实现,当然SDK也提供了简单办法。


复杂办法:直接扩展自BackupAgent抽象类,需要自行实现onBackup()和onRestore的细节。包括读取各类型文件并调用对应的Helper实现写入数据到备份文件中以及考虑旧的备份数据的迁移等处理。需要考虑很多细节,代码量很大

简单办法:扩展自系统封装好的BackupAgentHelper类并告知各类型文件对应的KEY和Helper实现即可,高效而简单,但没有提供大容量文件比如DB的备份实现

以扩展BackupAgentHelper的简单办法为例,演示下键值对备份的实现。


SP文件的话SDK提供了特定的SharedPreferencesBackupHelper实现

File文件对应的Helper实现为FileBackupHelper,只限于file目录的数据

其他类型文件比如Data和DB是没有预设Helper实现的,需要自行实现BackupHelper

// MyBackupAgent.kt
class MyBackupAgent: BackupAgentHelper() {
    override fun onCreate() {
        ...
        // Init helper for data, file, db and sp files.
        // Data和DB文件使用FileBackupHelper是无法备份的,此处单纯为了验证下
        FileBackupHelper(this, Constants.DATA_NAME).also { addHelper(Constants.BACKUP_KEY_DATA, it) }
        FileBackupHelper(this, Constants.DB_NAME).also { addHelper(Constants.BACKUP_KEY_DB, it) }
        // File和SP各自使用对应的Helper是可以备份的
        FileBackupHelper(this, Constants.FILE_NAME).also { addHelper(Constants.BACKUP_KEY_FILE, it) }
        SharedPreferencesBackupHelper(this, Constants.SP_NAME).also { addHelper(Constants.BACKUP_KEY_SP, it) }
    }
    ...
}

先用bmgr工具执行Backup,然后清除Demo的数据再执行Restore。从日志可以看出来键值对备份和恢复成功进行了。

// 开启bmgr和设置本地传输服务
>adb shell bmgr enabled
>adb shell bmgr transport com.android.localtransport/.LocalTransport
// Backup
>adb shell bmgr backupnow com.ellison.backupdemo
Running incremental backup for 1 requested packages.
Package @pm@ with result: Success
Package com.ellison.backupdemo with result: Success
Backup finished with result: Success
// 清空数据
>adb shell pm clear com.ellison.backupdemo
// 查看Backup Token
>adb shell dumpsys backup
...
Ancestral: 0
Current:   1
// Restore
>adb shell bmgr restore 01 com.ellison.backupdemo
Scheduling restore: Local disk image
restoreStarting: 1 packages
onUpdate: 0 = com.ellison.backupdemo
restoreFinished: 0
done

Demo的截图显示File和SP备份和恢复成功了。但存放在Data目录的海报和DB目录都失败了。这也验证了上述的结论。

20200308234636925.jpg

因为出于备份文件空间的考虑,官方并不建议针对DB文件等大容量文件做键值对备份。理论上可以扩展FileBackupHelper对Data和DB文件做出支持。但Google将关键的备份实现(FileBackupHelperBase和performBackup_checked())对外隐藏,使得简单扩展变得不可能。


StackOverFlow上针对这个问题有过热烈的讨论,唯一的办法是完全自己实现,但随着自动备份的出现,这个问题似乎已经不再重要。

https://stackoverflow.com/questions/5282936/android-backup-restore-how-to-backup-an-internal-database#

ⅱ.手动发起备份

BackupManager的dataChanged()函数可以告知系统App数据变化了,可以安排备份操作。我们在Demo的Backup Button里添加调用。

class LocalData @Inject constructor(...
                                    val backupManager: BackupManager){
    fun backupData() {
        backupManager.dataChanged()
    }
    ...
}

点击这个Backup Button之后等几秒钟,发现Demo的备份任务被安排进Schedule里,意味着备份操作将被系统发起。

>adb shell dumpsys backup
Pending key/value backup: 3
    BackupRequest{pkg=com.ellison.backupdemo} ★
    ...

我们可以强制这个Schedule的执行,也可以等待系统的调度。

>adb shell bmgr run
BackupManagerService: clearing pending backups
PFTBT   : backupmanager pftbt token=604faa13
...
BackupManagerService: [UserID:0] awaiting agent for ApplicationInfo{7b6a019 com.ellison.backupdemo}
BackupRestoreAgent: onCreate()
BackupManagerService: [UserID:0] agentConnected pkg=com.ellison.backupdemo agent=android.os.BinderProxy@be4cabf
BackupManagerService: [UserID:0] got agent android.app.IBackupAgent$Stub$Proxy@4eab58c
BackupRestoreAgent: onBackup() ★
BackupRestoreAgent: onDestroy()
BackupManagerService: [UserID:0] Released wakelock:*backup*-0-1265

20200308234636925.jpg

ⅲ.手动发起恢复

除了bmgr工具提供的restore以外还可以通过代码手动触发恢复。但这并不安全会影响应用的数据一致性,所以恢复的API requestRestore()废弃了。


我们来验证下,在Demo的Restore Button里添加BackupManager#requestRestore()的调用。

class LocalData @Inject constructor(...
                                    val backupManager: BackupManager){
    fun restoreData() {
        backupManager.requestRestore(object: RestoreObserver() {
            ...
        })
    }
    ...
}

但点击Button之后等一段时间,恢复的日志没有出现,反倒是弹出了无效的警告。

BackupRestoreApp: LocalData#restoreData()
BackupManager: requestRestore(): Since Android P app can no longer request restoring of its backup.

ⅳ.备份版本不一致的处理

版本不一致意味着恢复之后的逻辑可能会受到影响,这是我们在定制Backup功能时需要着重考虑的问题。


版本不一致的情况有两种。


现在运行的应用版本比备份时候的版本高,比较常见的场景

现在运行的应用版本比备份时候的版本低,即App降级,不太常见

默认情况下系统会无视App降级的恢复操作,意味着BackupAgent#onRestore()永远不会被回调。


但如果应用对于旧版本数据的兼容处理比较完善,希望支持降级的情况。那么需要在Manifest里打开restoreAnyVersion属性,系统将意识到你的兼容并包并回调你的onRestore处理。


无论哪种情况都可以在BackupAgent#onRestore()回调里拿到备份时的版本。然后读取App当前的VersionCode,执行对应的数据迁移或丢弃处理。

class MyBackupAgent: BackupAgentHelper() {
    ...
    override fun onRestore(
        data: BackupDataInput?,
        appVersionCode: Int,
        newState: ParcelFileDescriptor?
    ) {
        val packageInfo = packageManager.getPackageInfo(packageName, 0)
        if (packageInfo.versionCode != appVersionCode) {
            // Do something.
            // 可以调用BackupDataInput#restoreEntity()
            // 或skipEntityData()决定恢复还是丢弃
        } else {
            super.onRestore(data, appVersionCode, newState)
        }
    }
}

ⅴ.直接扩展BackupAgent

扩展自BackupAgent的需要考虑诸多细节,对这个方案有兴趣的朋友可以参考BackupAgentHelper的源码,也可以查阅官方说明。

https://developer.android.google.cn/guide/topics/data/keyvaluebackup

Ⅴ. 系统App的Backup限制

部分系统App的隐私级别较高,即便手动调用了Backup命令,系统仍将无视。并在日志中给出提示。

BackupManagerService: Beginning adb backup...
BackupManagerService: Starting backup confirmation UI, token=1763174695
BackupManagerService: Waiting for backup completion...
BackupManagerService: acknowledgeAdbBackupOrRestore : token=1763174695 allow=true
BackupManagerService: --- Performing adb backup ---
BackupManagerService: Package com.android.phone is not eligible for backup, removing.★提示该App不适合备份操作
BackupManagerService: Adb backup processing complete.
BackupManagerService: Full backup pass complete.

这个限制的源码在AppBackupUtils中,解决办法很简单在Manifest文件里明确指定BackupAgent。


其实Google的意图很清楚,这些系统级别的App数据要是被窃取将十分危险,默认禁止这个操作。但如果你指定了Backup代理那代表开发者考虑到了备份和恢复的场景,对这个操作进行了默许,备份操作才会被放行。

Ⅵ. 实战总结

ⅰ. Backup定制的总结

当我们遇到Backup定制任务的时候认真思考下需求再对症下药。为使得这个流程更加直观,做了个流程图分享给大家。

20200308234636925.jpg

ⅱ. Backup相关属性

1672149792097.png

三、结语

Android 12 Beta版公开在即,针对Backup功能又做了些改善。主要体现在两个方面,一是将备份规则针对云端备份和设备到设备备份两种场景区分开来,更加合理;二是加大adb backup命令的限制,对Backup功能可能造成的数据泄露进行了封堵。


针对Backup功能的持续改善足以瞥见这个功能的重要性。开发者需要对这些改善保持关注,不断调整Backup功能的开发策略,强化用户的数据安全。给大家一些实用建议。


厂商针对Backup功能的Transport扩展可以是Google云盘也可以是国内服务器,App开发者需要关注自己的备份需求和安全策略

思考App是否支持备份,明确开关allowBackup属性

更为推荐空间更大、定制灵活的自动备份模式

尽快适配Android 12封堵数据泄露的风险

隐私级别很高的数据可以补充设备加密的备份条件在备份阶段拦截

复写BackupAgent可以加入恢复的限制,灵活控制流程,在恢复阶段二次拦截

四、DEMO

https://github.com/ellisonchan/BackupRestoreApp

  • 提供了键值对备份模式的实现
  • 针对自动备份模式预设了备份规则,并定制了限制备份源的恢复流程

参考资料

备份功能的官方主页

键值对备份模式

自动备份模式

推荐阅读

全面复盘Android开发者容易忽视的Backup功能

Jetpack Hilt有哪些改善又有哪些限制?

除了SQLite一定要试试Room

相关实践学习
阿里云百炼xAnalyticDB PostgreSQL构建AIGC应用
通过该实验体验在阿里云百炼中构建企业专属知识库构建及应用全流程。同时体验使用ADB-PG向量检索引擎提供专属安全存储,保障企业数据隐私安全。
AnalyticDB PostgreSQL 企业智能数据中台:一站式管理数据服务资产
企业在数据仓库之上可构建丰富的数据服务用以支持数据应用及业务场景;ADB PG推出全新企业智能数据平台,用以帮助用户一站式的管理企业数据服务资产,包括创建, 管理,探索, 监控等; 助力企业在现有平台之上快速构建起数据服务资产体系
相关文章
|
22天前
|
Java Android开发 UED
🧠Android多线程与异步编程实战!告别卡顿,让应用响应如丝般顺滑!🧵
【7月更文挑战第28天】在Android开发中,确保UI流畅性至关重要。多线程与异步编程技术可将耗时操作移至后台,避免阻塞主线程。我们通常采用`Thread`类、`Handler`与`Looper`、`AsyncTask`及`ExecutorService`等进行多线程编程。
35 2
|
3天前
|
缓存 数据处理 Android开发
Android经典实战之Kotlin常用的 Flow 操作符
本文介绍 Kotlin 中 `Flow` 的多种实用操作符,包括转换、过滤、聚合等,通过简洁易懂的例子展示了每个操作符的功能,如 `map`、`filter` 和 `fold` 等,帮助开发者更好地理解和运用 `Flow` 来处理异步数据流。
22 4
|
3天前
|
API Android开发 开发者
Android经典实战之使用ViewCompat来处理View兼容性问题
本文介绍Android中的`ViewCompat`工具类,它是AndroidX库核心部分的重要兼容性组件,确保在不同Android版本间处理视图的一致性。文章列举了设置透明度、旋转、缩放、平移等功能,并提供了背景色、动画及用户交互等实用示例。通过`ViewCompat`,开发者可轻松实现跨版本视图操作,增强应用兼容性。
20 5
|
8天前
|
缓存 API Android开发
Android经典实战之Kotlin Flow中的3个数据相关的操作符:debounce、buffer和conflate
本文介绍了Kotlin中`Flow`的`debounce`、`buffer`及`conflate`三个操作符。`debounce`过滤快速连续数据,仅保留指定时间内的最后一个;`buffer`引入缓存减轻背压;`conflate`仅保留最新数据。通过示例展示了如何在搜索输入和数据流处理中应用这些操作符以提高程序效率和用户体验。
20 6
|
6天前
|
API Android开发 开发者
Android经典实战之用WindowInsetsControllerCompat方便的显示和隐藏状态栏和导航栏
本文介绍 `WindowInsetsControllerCompat` 类,它是 Android 提供的一种现代化工具,用于处理窗口插入如状态栏和导航栏的显示与隐藏。此类位于 `androidx.core.view` 包中,增强了跨不同 Android 版本的兼容性。主要功能包括控制状态栏与导航栏的显示、设置系统窗口行为及调整样式。通过 Kotlin 代码示例展示了如何初始化并使用此类,以及如何设置系统栏的颜色样式。
27 2
|
6天前
|
图形学 Android开发
小功能⭐️Unity调用Android常用事件
小功能⭐️Unity调用Android常用事件
|
6天前
|
API Android开发 Kotlin
Android实战经验分享之如何获取状态栏和导航栏的高度
在Android开发中,掌握状态栏和导航栏的高度对于优化UI布局至关重要。本文介绍两种主要方法:一是通过资源名称获取,简单且兼容性好;二是利用WindowInsets,适用于新版Android,准确性高。文中提供了Kotlin代码示例,并对比了两者的优缺点及适用场景。
41 1
|
10天前
|
自然语言处理 定位技术 API
Android经典实战之如何获取图片的经纬度以及如何根据经纬度获取对应的地点名称
本文介绍如何在Android中从图片提取地理位置信息并转换为地址。首先利用`ExifInterface`获取图片内的经纬度,然后通过`Geocoder`将经纬度转为地址。注意操作需在子线程进行且考虑多语言支持。
34 4
|
17天前
|
XML 存储 Android开发
Android实战经验之Kotlin中快速实现MVI架构
本文介绍MVI(Model-View-Intent)架构模式,强调单向数据流与不可变状态管理,提升Android应用的可维护性和可测试性。MVI分为Model(存储数据)、View(展示UI)、Intent(用户动作)、State(UI状态)与ViewModel(处理逻辑)。通过Kotlin示例展示了MVI的实现过程,包括定义Model、State、Intent及创建ViewModel,并在View中观察状态更新UI。
54 12
|
17天前
|
XML Android开发 数据格式
Android实战经验之Kotlin中快速实现动态更改应用图标和名称
本文介绍在Android中通过设置多个活动别名动态更改应用图标和名称的方法,涉及XML配置及Kotlin代码示例。
52 10