资讯动态

【Jetpack Compose娓娓道来】 第17课:安全、权限与隐私——容易被忽视的“隐形”必修课

发布时间:2026/9/26 4:05:57 来源:尧图企业网站定制
一、回顾与引入前十六课我们一路走来从Compose的基本概念、布局、状态、列表、导航、主题、动画手势、自定义绘制到与View互操作、性能优化、测试调试、架构分层、协程深度结合、Compose Multiplatform、生产级项目实战、性能优化进阶基本上把Compose开发的方方面面都走了一遍。但有一个话题我们几乎从未正面触及却在真实的生产环境中至关重要——安全、权限与隐私。你可能会想“我做的是UI安全是后端的事。”或者“Compose不是默认就安全吗”这两种想法都很危险。第一UI层是攻击面的一部分。截屏、点击劫持、任务劫持、剪贴板泄露——这些攻击直接发生在UI层和后端安全没有任何关系。第二Compose不会自动让你的应用更安全。正如一篇安全分析文章所指出的开发者常常误以为采用现代技术栈Kotlin Jetpack Compose就自动让应用变得安全了但像任务劫持或Intent注入这样的漏洞并不存在于UI层——它们存在于Android组件交互的架构中。第三权限和隐私是用户信任的基础。一个在不该请求权限时弹出权限框的应用或者一个不小心把用户敏感数据暴露在剪贴板里的应用会迅速失去用户信任。这一课我们就来补齐这块“隐形”的必修课。内容包括运行时权限的声明式管理、敏感数据的加密存储、防截屏与防点击劫持、安全输入的最佳实践、剪贴板保护、以及Compose Multiplatform场景下的安全考量。二、运行时权限声明式管理2.1 为什么权限需要专门讲Android的运行时权限自Android 6.0引入以来就是开发中的“老大难”。在传统View体系里你要用ActivityCompat.requestPermissions()、重写onRequestPermissionsResult()、处理各种回调状态。Compose虽然简化了UI但权限请求本质上仍然是一个异步的、涉及系统对话框的操作。好消息是Compose生态提供了两种成熟的方案Accompanist Permissions和Activity Result API rememberLauncherForActivityResult()。2.2 使用 rememberLauncherForActivityResult这是最轻量的方式不需要额外依赖ComposablefunCameraPermissionScreen(){varhasPermissionbyremember{mutableStateOf(false)}varshowRationalebyremember{mutableStateOf(false)}vallauncherrememberLauncherForActivityResult(contractActivityResultContracts.RequestPermission()){granted-hasPermissiongrantedif(!granted){// 用户拒绝了权限}}if(hasPermission){Text(相机权限已授予)}else{Column{Text(此功能需要相机权限)Button(onClick{launcher.launch(Manifest.permission.CAMERA)}){Text(请求权限)}}}}rememberLauncherForActivityResult是Activity Result API的Compose封装。它返回一个ManagedActivityResultLauncher你通过launch()发起请求结果在回调里处理。关键点launch()必须从非可组合作用域调用比如按钮的onClick回调而不是直接在组合阶段调用。2.3 使用 Accompanist PermissionsAccompanist Permissions提供了更声明式的API把权限状态直接映射为Compose可以观察的状态OptIn(ExperimentalPermissionsApi::class)ComposablefunFeatureThatRequiresCameraPermission(){valcameraPermissionStaterememberPermissionState(android.Manifest.permission.CAMERA)when(cameraPermissionState.status){PermissionStatus.Granted-{Text(相机权限已授予)}isPermissionStatus.Denied-{Column{valtextToShowif(cameraPermissionState.status.shouldShowRationale){// 用户拒绝了但可以展示理由相机功能对这个应用很重要请授予权限。}else{// 第一次请求或者用户选择了“不再询问”需要相机权限才能使用此功能。}Text(textToShow)Button(onClick{cameraPermissionState.launchPermissionRequest()}){Text(请求权限)}}}}}rememberPermissionState返回一个PermissionState它的status属性是PermissionStatus密封类包含Granted和Denied两种状态。Denied状态还带有shouldShowRationale属性帮助你决定是否展示解释性文字。对于多个权限使用rememberMultiplePermissionsStatevalpermissionsStaterememberMultiplePermissionsState(listOf(Manifest.permission.CAMERA,Manifest.permission.RECORD_AUDIO))// 检查所有权限是否授予if(permissionsState.allPermissionsGranted){// 全部授予}// 请求全部Button(onClick{permissionsState.launchMultiplePermissionRequest()}){Text(请求所有权限)}注意Accompanist Permissions的API目前标记为ExperimentalPermissionsApiAPI可能会变化。但它在生产环境中已经被广泛使用是事实上的标准。2.4 权限请求的最佳实践原则一按需请求不要一次性请求所有权限。在用户真正需要某个功能时再请求对应权限而不是在应用启动时弹出所有权限框。原则二解释为什么需要权限。在请求之前向用户说明权限的用途。shouldShowRationale就是为这个设计的。原则三优雅处理拒绝。用户拒绝了权限应用不应该崩溃或卡死。展示一个友好的提示引导用户去设置里开启。原则四检查权限是否已在Manifest中声明。运行时请求的权限必须在AndroidManifest.xml中声明否则请求会直接失败。三、敏感数据的加密存储3.1 什么数据需要加密不是所有数据都需要加密。一张状态管理论文提出了一个实用的决策框架根据数据的敏感程度选择存储策略临时UI状态只保留在内存中。快速、短暂不需要持久化。会话状态用SavedStateHandle持久化能扛住进程被杀死。持久化敏感数据用Keystore保护的密钥加密后存储比如EncryptedSharedPreferences或加密的Room数据库。需要加密的典型数据认证Token、API密钥、用户密码、个人身份信息PII、支付信息。不需要加密的数据用户偏好设置深色模式、语言、非敏感的缓存数据。3.2 使用 EncryptedSharedPreferencesEncryptedSharedPreferences是Jetpack Security库提供的API它用Android Keystore中的密钥自动加密所有数据valmasterKeyMasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()valencryptedPrefsEncryptedSharedPreferences.create(context,secure_prefs,masterKey,EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)// 存储敏感数据encryptedPrefs.edit().putString(auth_token,token).apply()// 读取valtokenencryptedPrefs.getString(auth_token,null)MasterKey由Android Keystore保护在大多数设备上是硬件支持的。加密和解密对开发者透明你像使用普通SharedPreferences一样使用它。3.3 在Compose中管理加密状态在Compose应用中加密数据的读写应该通过Repository完成ViewModel负责协调。可组合函数只接收解密后的数据不直接接触加密逻辑// Repository层interfaceSecureStorage{suspendfunsaveAuthToken(token:String)suspendfungetAuthToken():String?suspendfunclearAuthToken()}classEncryptedSecureStorageInjectconstructor(ApplicationContextprivatevalcontext:Context):SecureStorage{privatevalprefsbylazy{valmasterKeyMasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()EncryptedSharedPreferences.create(context,secure_prefs,masterKey,EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)}overridesuspendfunsaveAuthToken(token:String){prefs.edit().putString(auth_token,token).apply()}overridesuspendfungetAuthToken():String?prefs.getString(auth_token,null)overridesuspendfunclearAuthToken(){prefs.edit().remove(auth_token).apply()}}在ViewModel中注入并使用HiltViewModelclassAuthViewModelInjectconstructor(privatevalsecureStorage:SecureStorage):ViewModel(){funlogin(token:String){viewModelScope.launch{secureStorage.saveAuthToken(token)}}}关键原则加密数据的读写不应该发生在可组合函数里。可组合函数是纯UI逻辑加密操作是副作用应该在ViewModel或Repository中处理。3.4 在Compose Multiplatform中的加密存储CMP场景下EncryptedSharedPreferences是Android专属的。对于跨平台方案有几个库可以选择KSafe一个演示如何在Compose Multiplatform中存储加密数据的库支持Android和iOS。SecureVault-KMP使用AES-256-GCM加密支持Android、iOS和桌面平台。Kassaforte利用各平台原生安全APIAndroid Keystore、iOS Keychain进行加密存储。四、防截屏与防点击劫持4.1 防截屏FLAG_SECURE对于包含敏感信息的页面登录页、支付页、PIN码输入页应该禁止截屏和录屏。Android提供了FLAG_SECURE窗口标志ComposablefunSecureScreen(content:Composable()-Unit){valviewLocalView.currentDisposableEffect(Unit){valwindow(view.contextasActivity).window window.setFlags(WindowManager.LayoutParams.FLAG_SECURE,WindowManager.LayoutParams.FLAG_SECURE)onDispose{window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)}}content()}FLAG_SECURE的效果禁止截屏、禁止录屏、在最近任务列表中显示为空白/模糊缩略图。DisposableEffect保证只在进入这个页面时设置标志离开时清除。这样你不需要为整个应用禁用截屏只需要为敏感页面禁用。封装为ModifierfunModifier.secureScreen():Modifiercomposed{valviewLocalView.currentDisposableEffect(Unit){valwindow(view.contextasActivity).window window.setFlags(FLAG_SECURE,FLAG_SECURE)onDispose{window.clearFlags(FLAG_SECURE)}}this}// 使用Column(modifierModifier.secureScreen()){// 敏感内容}注意FLAG_SECURE是Android专属的。在Compose Multiplatform中iOS没有直接等价的API需要额外的平台适配工作。4.2 防点击劫持点击劫持Tapjacking攻击发生在恶意应用在你的界面上方绘制一个透明层用户以为在点击透明层实际上触摸事件穿透到了下面的你的应用——可能点击了“确认转账”这样的敏感按钮。全局防护在托管Compose内容的根View上启用filterTouchesWhenObscuredComposablefunAntiTapjacking(){valviewLocalView.currentLaunchedEffect(Unit){view.filterTouchesWhenObscuredtrue}}在MainActivity的setContent中调用这个函数就能为整个应用提供防护。组件级防护如果不想阻止整个屏幕的触摸可以用pointerInteropFilter只保护特定的敏感组件。注意从Android 12API 31开始系统会自动阻止来自“不可信”覆盖层的触摸。但对于minSdk 31的应用手动设置这个标志仍然是必要的。4.3 任务劫持StrandHogg任务劫持是Android上的一个经典漏洞恶意应用通过操纵taskAffinity把自己的Activity插入到你的任务栈中伪装成你的登录页面来窃取凭证。防护方式在AndroidManifest.xml中为MainActivity设置launchModesingleTaskactivityandroid:name.MainActivityandroid:exportedtrueandroid:launchModesingleTask对于金融类等高安全要求的应用还建议在启动时增加运行时检查确保Activity没有被启动在外来的任务历史中。五、安全输入的最佳实践5.1 密码输入BasicSecureTextFieldCompose提供了BasicSecureTextField组件用于密码输入varpasswordbyremember{mutableStateOf()}varshowPasswordbyremember{mutableStateOf(false)}BasicSecureTextField(valuepassword,onValueChange{passwordit},textObfuscationModeif(showPassword){TextObfuscationMode.Visible}else{TextObfuscationMode.RevealLastTyped},modifierModifier.fillMaxWidth(),trailingIcon{IconButton(onClick{showPassword!showPassword}){Icon(imageVectorif(showPassword)Icons.Default.VisibilityelseIcons.Default.VisibilityOff,contentDescriptionif(showPassword)隐藏密码else显示密码)}})TextObfuscationMode.RevealLastTyped是默认行为只显示最后一个输入的字符其余用圆点代替。这是安全的默认值但如果你想完全隐藏连最后一个字符也不显示可以用TextObfuscationMode.Hidden。5.2 自定义PIN码键盘对于PIN码输入这样的高安全场景推荐使用自定义应用内键盘而不是系统键盘。原因第三方键盘IME可能记录你的输入而自定义键盘的按键事件完全在你的进程内处理不经过系统输入法。ComposablefunSecurePinKeypad(onPinComplete:(String)-Unit){valviewLocalView.currentDisposableEffect(Unit){valwindow(view.contextasActivity).window window.setFlags(FLAG_SECURE,FLAG_SECURE)onDispose{window.clearFlags(FLAG_SECURE)}}varpinbyremember{mutableStateOf()}Column(horizontalAlignmentAlignment.CenterHorizontally){// 显示已输入位数的圆点Row(Modifier.padding(24.dp)){repeat(4){i-Box(Modifier.size(16.dp).padding(4.dp).background(if(ipin.length)Color.BlackelseColor.LightGray,CircleShape))}}valkeyslistOf(1,2,3,4,5,6,7,8,9,,0,⌫)keys.chunked(3).forEach{row-Row{row.forEach{key-Button(onClick{when(key){-{}⌫-pinpin.dropLast(1)else-if(pin.length4){pinkeyif(pin.length4)onPinComplete(pin)}}},enabledkey.isNotEmpty(),modifierModifier.size(72.dp).padding(4.dp)){Text(key,fontSize20.sp)}}}}}}这个自定义键盘同时做了两件事FLAG_SECURE防截屏不经过系统输入法。PIN码只存在于内存中永远不会接触到IME、自动填充或剪贴板。六、剪贴板保护6.1 为什么剪贴板是风险点剪贴板是应用之间传递数据的通道但也是一个数据泄露的通道。恶意应用可以读取剪贴板内容如果用户从你的应用复制了敏感信息密码、银行卡号、身份证号这些信息可能被窃取。6.2 在Compose中保护剪贴板Compose使用LocalClipboardManager来访问剪贴板。你可以通过CompositionLocalProvider替换为受保护的剪贴板管理器ComposablefunProtectedClipboard(content:Composable()-Unit){// 在敏感页面中使用受限的剪贴板管理器valprotectedClipboardremember{object:ClipboardManager{overridefunsetText(annotatedString:AnnotatedString){// 可以选择禁止写入或只允许写入非敏感内容}overridesuspendfungetText():AnnotatedString?null}}CompositionLocalProvider(LocalClipboardManager provides protectedClipboard){content()}}如果你的应用使用了SAP Mobile Services这样的企业级SDK它提供了ClipboardProtectionService需要把sdkClipboardManager通过CompositionLocalProvider提供给Compose内容。6.3 最佳实践敏感页面禁用复制在登录页、支付页等敏感页面通过自定义ClipboardManager阻止复制操作。敏感字段禁用自动填充对于密码、PIN码等字段禁用自动填充。谨慎使用系统剪贴板能不用就不用。如果必须用考虑只在用户明确操作时使用并在一段时间后自动清除。七、Compose Multiplatform的安全考量7.1 跨平台安全的挑战CMP的安全模型和Android不同。FLAG_SECURE、EncryptedSharedPreferences、filterTouchesWhenObscured都是Android专属的。在共享代码中你需要通过expect/actual为每个平台提供安全实现。7.2 跨平台加密存储// commonMainexpectclassSecureStorage{suspendfunsave(key:String,value:String)suspendfunget(key:String):String?suspendfunremove(key:String)}// androidMainactualclassSecureStorage(context:Context){// 使用EncryptedSharedPreferences实现}// iosMainactualclassSecureStorage{// 使用Keychain Services实现}跨平台安全库如Kassaforte和SecureVault-KMP已经提供了这样的抽象你可以直接使用。7.3 跨平台截屏防护Android用FLAG_SECUREiOS用UIScreen.isCaptured检测和secureTextEntry伪装。CMP中没有统一的API需要在平台层分别实现。八、常见陷阱8.1 在可组合函数中直接做加密操作// 错误加密操作是副作用不应该在组合中执行ComposablefunBad(){valencryptedencrypt(sensitiveData)// 每次重组都执行Text(encrypted)}加密操作应该在ViewModel或Repository中执行可组合函数只接收结果。8.2 忘记清除FLAG_SECURE// 错误设置了但没清除导致整个应用都无法截屏window.setFlags(FLAG_SECURE,FLAG_SECURE)// 没有onDispose清理用DisposableEffect确保离开页面时清除。8.3 在Manifest中遗漏权限声明运行时请求的权限必须在AndroidManifest.xml中声明。忘记声明会导致请求直接失败而不是弹出权限对话框。8.4 一次性请求所有权限在应用启动时就请求相机、位置、通讯录、存储等所有权限用户体验极差而且用户很可能全部拒绝。8.5 不处理权限被永久拒绝的情况用户选择了“不再询问”后requestPermissions不会再弹出对话框。你需要检测这个状态并引导用户去系统设置中手动开启。8.6 在Compose Multiplatform中直接使用Android安全API// 错误commonMain中不能使用Android专属APIimportandroid.view.WindowManagerComposablefunBad(){valwindow(LocalView.current.contextasActivity).window window.setFlags(FLAG_SECURE,FLAG_SECURE)// commonMain中编译失败}用expect/actual把平台专属实现抽象出来。九、小结与下一课预告这一课我们补齐了Compose开发中容易被忽视的安全、权限与隐私这块拼图。关键点回顾运行时权限用rememberLauncherForActivityResult或 Accompanist Permissions 声明式管理权限。按需请求、解释用途、优雅处理拒绝。敏感数据加密用EncryptedSharedPreferences配合Android Keystore加密敏感数据。加密操作在Repository/ViewModel层执行不放在可组合函数中。防截屏用FLAG_SECURE保护敏感页面用DisposableEffect确保进入时设置、离开时清除。防点击劫持用filterTouchesWhenObscured阻止透明覆盖层的触摸穿透。任务劫持防护在Manifest中设置launchModesingleTask。安全输入用BasicSecureTextField配合TextObfuscationMode高安全场景用自定义应用内键盘。剪贴板保护在敏感页面替换LocalClipboardManager阻止敏感数据复制。CMP安全用expect/actual抽象平台安全能力或使用Kassaforte、SecureVault-KMP等跨平台安全库。常见陷阱组合中做加密操作、忘记清除FLAG_SECURE、遗漏Manifest权限、一次性请求所有权限、不处理永久拒绝。安全不是一次性任务而是持续的过程。每次添加新功能时问自己这个功能会接触敏感数据吗需要权限吗需要防截屏吗需要加密存储吗养成这个习惯你的应用就会在安全上领先大多数。下一课也是这个系列真正意义上的最后一课我们会做一次Compose开发者的完整工具箱盘点。把前十七课涉及的所有工具、库、资源、学习路径做一个系统性的整理包括官方文档和Codelab、Android Studio的Compose工具、调试和性能分析工具、测试工具、第三方库生态、社区资源、以及从初学者到高级开发者的成长路线图。这是对整个系列的收官和总结。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑