首页 / 资讯中心 / 文章详情

Android举报申诉功能开发实战:图片上传、FileProvider与状态一致性

Android举报申诉功能开发实战:图片上传、FileProvider与状态一致性 ★ FEATURED ARTICLE
刚开始接手这个需求的时候我其实有点轻敌——举报功能嘛无非就是一个表单加几张图片后端存一下运营后台看一眼结果。结果真正做下来才发现android app举报申诉功能牵扯到的细节远比想象中多得多尤其是当你要把“举报”和“申诉”两套流程都做好、做稳、做到线上不出幺蛾子的时候很多坑是文档里根本不会写清楚的。这篇就把我们整个实现过程里的思路、方案和踩过的坑整理出来给正在做同类功能的同学一个参考。功能本身解决的是这样一类问题用户在app里看到垃圾内容、诈骗信息、违规评论需要一键举报而被举报的用户如果觉得处理结果有误需要有一个渠道发起申诉。这两个动作看似都是“提交一些内容等平台处理”但业务属性完全不同技术上的处理方式也因此要分开设计。下面我从业务建模、文件上传、系统适配、状态同步到线上排查按我们实际开发的顺序一条条讲。1. 业务建模举报和申诉为什么必须拆成两套体系而不是一个按钮1.1 两个流程的本质差异决定了它们不能用同一套接口很多第一次做这个功能的人会下意识地觉得举报和申诉不就是同一个表单填两遍吗其实大不一样。举报的主体是“平台上的任意内容”举报人不需要身份标识甚至可以匿名平台要做的核心是审核内容是否违规所以举报单要记录的是被举报的内容ID、内容类型、举报原因分类、举报人ID可选、证据截图。而申诉的主体是“被处理的账号或内容”发起人必须是当事人平台要做的核心是复核当初的判罚是否合理所以申诉单要记录的是被举报单ID或处罚单ID、申诉理由、新的证据材料、申诉人身份凭证。这两个流程的生命周期也不一样。举报单的终点是“违规成立/不成立”申诉单的终点是“申诉通过/驳回”而且申诉过程中往往不影响原始处罚的生效。最典型的例子一个账号因为被举报发布了违禁内容而被禁言禁言是立即生效的用户同时发起申诉在申诉结果出来之前禁言状态不解除。如果把两套状态混在一个模型里后面做审核后台的时候会非常痛苦。我们最终把两张表分开设计// 举报单 data class ReportTicket( val id: String, val targetType: String, // CONTENT / USER / COMMENT val targetId: String, val reasonCode: String, val evidences: ListString, // 图片URL列表 val reporterId: String?, // 允许为空 val status: ReportStatus, val createdAt: Long ) // 申诉单 data class AppealTicket( val id: String, val relatedTicketId: String, // 关联的举报单或处罚单 val appealReason: String, val evidences: ListString, val appellantId: String, // 必填 val status: AppealStatus, val handledAt: Long? )这样设计的好处是后期权限控制和数据统计都很方便举报可以做成免登录提交虽然我们最终要求登录了但表结构支持匿名申诉必须登录并且绑定具体处罚单避免一个人替别人申诉或者对不存在的处罚单申诉。1.2 状态机的设置要有“预留位”而不是拍脑袋状态流转是我们开发中反复推翻重来最多的地方。最初我们只设计了三个状态待审核、已通过、已驳回。后来运营提了一个需求同一个举报单被重复举报多次需要合并处理再后来又加了“被举报内容已自删举报单自动关闭”的逻辑。所以最终的状态模型比最初多了将近一倍举报单状态PENDING待审核→PROCESSING审核中→RESOLVED违规成立→REJECTED违规不成立→CLOSED自动关闭比如内容已删除申诉单状态PENDING→PROCESSING→APPEAL_ACCEPTED申诉通过撤销处罚→APPEAL_DISMISSED申诉驳回维持原判→EXPIRED超过处理时限未审核自动驳回这里要特别提醒一个点状态机一定要独立于业务操作存在。什么意思比如“申诉通过”这个动作对应的业务操作是“撤销处罚”但撤销处罚本身可能因为处罚单已经过期而失败。如果先把状态改成通过再去做撤销操作一旦失败就会产生状态不一致。正确做法是先执行业务操作成功后再更新状态。我们在开发中就遇到过好几次这种顺序问题后面专门做了一个事务性处理业务操作和状态更新放在同一个API里完成由服务端保证原子性。2. 证据上传的多图处理与进度反馈把“选图”变成“上传”的体验细节2.1 图片选择器的兼容处理与压缩策略举报和申诉都需要附带证据图片我们限定最多九张这个数量是产品和运营商量出来的既能覆盖大多数场景又不会让上传链路太长。图片选择环节最容易出问题的是低版本Android机型上的系统相册Intent差异以及部分国产ROM的图库返回的不是真实路径而是Uri。我们的做法是放弃系统的ACTION_PICK直接对接各机型都兼容的方案优先用系统PhotoPickerAndroid 13老版本用自研的相册列表。如果你不想引入第三方图片选择库一个折中方案是用ACTION_GET_CONTENT它返回一个Uri然后通过ContentResolver读取不需要处理DATA_COLUMN_INDEX拿路径这种老办法。但注意ACTION_GET_CONTENT无法多选多选还是得自己写或者引库。图片压缩是上传体验的重中之重。我们实测过一张主流手机拍出来的照片基本在3MB到8MB之间直接原图传上去不仅用户等得心烦服务端存储成本也直线上升。我们的压缩策略是尺寸压缩加质量压缩结合fun compressImage(context: Context, uri: Uri, maxWidth: Int 1080, maxHeight: Int 1920, quality: Int 80): File { val bounds ImageDecoder.decodeBounds(context.resources, uri) val sampleSize calculateSampleSize(bounds.width(), bounds.height(), maxWidth, maxHeight) val decoder ImageDecoder.createSource(context.contentResolver, uri) val bitmap decoder.decodeBitmap { it.targetSize Size(maxWidth, maxHeight); it.allocator ImageDecoder.ALLOCATOR_SOFTWARE } val output File(context.cacheDir, ${System.currentTimeMillis()}.jpg) FileOutputStream(output).use { fos - bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos) } return output }目标压缩到单张不超过300KB九张图总量控制在2.7MB以内这样在弱网环境下也能在合理时间内完成上传。这里有个细节ImageDecoder只支持Android 8.0以上老机型需要用BitmapFactory配合inSampleSize自己采样代码要写两套分支。另外压缩后的Bitmap一定要recycle()或让GC及时回收否则多张连续压缩在低内存机器上很容易触发OOM。我们最初在测试机上连续选九张图压缩崩了两次就是因为没有及时释放Bitmap。2.2 上传进度与失败重试别让用户对着一个转圈干等上传进度是一个看着简单做起来很琐碎的事。Android原生ProgressBar配合OkHttp的RequestBody可以实现字节级进度回调但要注意这个回调频率非常高如果直接在主线程刷新UI会造成明显卡顿需要做节流处理。我们定义了一个单例的上传管理器每张图一个任务任务之间有独立的进度界面顶部显示一个总进度条每个图片格子下面显示单张进度。这里推荐一个实用做法不要用ProgressDialog这种模态弹窗因为用户上传九张图的时间可能超过一分钟模态弹窗会阻断一切操作体验很糟。用非模态的进度展示让用户在上传过程中可以继续填写文字内容是这类表单页比较成熟的设计。失败重试也很有讲究。我们的方案是上传失败后图片格子上显示一个红色重试按钮点击只重传这一张而不是整单重新提交。整单重传的风险在于已经上传成功的图片会被重复上传产生孤儿文件服务端虽然可以做去重但没必要给自己找这个麻烦。3. Android 7以上拍照取证的FileProvider适配最容易在真机上崩溃的一环3.1 FileUriExposedException是怎么“阴”到你的我们开发时遇到的最典型的崩溃是在Android 7.0以上的真机上用FileProvider之前的老代码Uri.fromFile(file)直接传给相机一拍照就闪退Logcat里报FileUriExposedException。很多人喜欢在模拟器上测模拟器默认可能不会触发这个问题于是一提交到线上就炸。原理其实很简单Android 7.0之后系统禁止app向其他app暴露file://方式的Uri因为你把本地路径这种真实文件系统的绝对路径发给别人的进程接收方一旦有恶意代码就可以顺着路径去读你app的私有目录。正确做法是用content://替代由FileProvider生成一个临时授权的Uri让相机app在授权范围内访问到那个文件。这个问题的隐蔽之处在于它只在真机上并且是特定Android版本上出现而且很多第三方图片选择库内部已经做了适配所以单独用库没感觉一换成我们自己调系统相机拍证据照就立刻踩雷。热词里能看到不少类似content://com.baidu.searchbox.fileprovider这种长串其实都是各个app配置的authorities字段。3.2 FileProvider配置与统一Uri获取我们最终把所有涉及文件Uri的操作都收敛到一个工具类里统一出口避免到处散落Uri.fromFile。第一步是清单文件注册Providerprovider 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 / /providerfile_paths.xml里面要覆盖你所有可能产生文件共享的路径。我们当时踩过一个坑cache-path和external-cache-path少配一个导致压缩缓存文件在有的机型上找不到。建议把这几条都写上?xml version1.0 encodingutf-8? paths cache-path namecache path. / external-cache-path nameexternal_cache path. / files-path namefiles path. / external-files-path nameexternal_files path. / /paths第二步是获取相机拍照的输出Urifun createImageUri(context: Context): Uri { val imageFile File.createTempFile(evidence_${System.currentTimeMillis()}, .jpg, context.cacheDir) return FileProvider.getUriForFile(context, ${context.packageName}.fileprovider, imageFile) }注意File.createTempFile生成的随机后缀名如果一圈九张图每个都先拍照然后上传一定要保证文件名绝对不重复。我们之前用过System.currentTimeMillis()后来发现在同一毫秒内连续拍两张照片的概率虽然极低但存在改成时间戳自增序号才稳。第三步是拍照Intent要加上EXTRA_OUTPUT并授予读权限。这里有个特别容易忽略的点相机app需要的权限不只是写还可能需要读所以在Intent上要显式加上FLAG_GRANT_READ_URI_PERMISSION和FLAG_GRANT_WRITE_URI_PERMISSION。我们最初只加了写权限少数机型上拍照后返回的缩略图正常但原图读取抛异常排查了半天。3.3 相册、拍照、裁剪三条路径都要走同一个出口除了拍照从相册选择图片后如果需要做裁剪同样要小心。很多裁剪库比如老牌的uCrop内部已经处理了Uri转换但你自己拼接Intent走系统裁剪时输入Uri和输出Uri都要用FileProvider包装否则在Android 10以上的分区存储环境下直接从相册返回的Uri去裁剪会得到一张空图。我们的最终方案是相册选图拿到的Uri先拷贝到我们自己的缓存目录转成文件然后再统一通过压缩工具处理后展示。这样做的代价是多一次IO拷贝但换来的好处是所有后续逻辑包括裁剪、上传、缩略图生成都只需要面对一个“我们自己缓存目录下的文件”不会再被各种来源的Uri搞得焦头烂额。这一章值得多说一句content://开头的Uri不要试图直接拿getPath()去当文件路径用很多新手在这里写uri.path然后拼接成字符串去读写文件拿到的是一串带/external/images/media/12345这样的虚拟路径真实文件系统的绝对路径根本不是这个。正确做法永远是先通过ContentResolver.openInputStream读取内容再写到你的目标文件里。4. 状态流转与多端一致性接口幂等与服务端状态冲突的处理4.1 重复提交按钮的幂等设计客户端最典型的状况是用户手抖点两次“提交”如果不做处理服务端会生成两条一模一样的举报单。我们的做法是客户端生成一个UUID作为requestId提交时带上服务端用requestId做唯一索引重复请求直接返回第一次的结果。这个方案比单纯按钮置灰靠谱因为用户可能存在弱网环境下的重试置灰按钮只能挡同一页面内的重复点击挡不住网络层重发。服务端逻辑大致是fun submitAppeal(appeal: AppealTicket): SubmitResult { synchronized(requestLock) { val exists appealRepository.findByRequestId(appeal.requestId) return if (exists ! null) { SubmitResult(exists.id, duplicate) } else { val id appealRepository.insert(appeal) SubmitResult(id, created) } } }synchronized只是为了防止单机并发多实例部署时要用数据库唯一索引或者Redis分布式锁这个根据你的服务端架构来选。4.2 服务端并发的状态覆盖问题另一个我们踩过的坑是状态覆盖。假设用户先提交了一个申诉单状态是PENDING运营后台审核通过状态变成APPEAL_ACCEPTED但客户端某个旧版本的逻辑里在用户再次打开申诉详情页时居然又提交了一次PENDING状态上去把已经变成APPEAL_ACCEPTED的状态覆盖回去了。原因就是客户端在详情页有一个“重新提交”的接口调用没有做状态校验。这个问题的教训是客户端绝不能直接传一个完整状态对象给服务端更新服务端必须根据当前状态决定本次操作是否合法。比如只有PENDING状态的申诉单才能被审核审核后置为终态终态不可逆。终态的更新只能由服务端内部触发客户端传来的状态一律忽略。我们后来把所有状态变更都收敛到了服务端枚举的“动作”上submit动作只能由NON_EXISTENT触发新建一个PENDING单handle动作由PENDING触发进入PROCESSING或直接变为终态accept动作由PROCESSING触发变为APPEAL_ACCEPTEDdismiss动作由PROCESSING触发变为APPEAL_DISMISSED这样就从流程上堵死了状态乱跳的可能。4.3 深链跳转从通知到申诉页的直达通道申诉处理完成后用户会收到一条站内推送点击推送要能直接落到对应的申诉详情页。我们用的是android-app://开头或者自己定义的scheme。自定义scheme有个麻烦如果app没安装链接点了没反应如果已经安装但页面进程被杀冷启动后必须能正确解析路径参数跳到目标页。我们在Manifest里配置了一个专门的Activity接收深链intent-filter action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schememyapp android:hostappeal / /intent-filter然后在这个Activity里根据path参数拿到appealId再通过Router跳转到详情页。这里有一个实际遇到的细节如果app是冷启动onCreate里收到的Intent就是这个深链onNewIntent可能不会回调如果是热启动MainActivity可能还在栈里情况又不同。稳妥做法是onCreate和onNewIntent都处理并且统一走同一个“解析Intent并路由”的方法。class DeepLinkActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleIntent(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) handleIntent(intent) } private fun handleIntent(intent: Intent?) { val appealId intent?.data?.getQueryParameter(appealId) // 跳转到申诉详情页 } }5. 上线之后真机验证、崩溃排查与数据一致性的进一步保障5.1 真机验证清单模拟器给不了你的安全感举报申诉功能上线前我们自己列了一张真机验证清单每一条都是开发过程中真实踩过或者预期会踩的坑Android 7.0真机拍照后必须能正常返回图片不报FileUriExposedExceptionAndroid 10及以上相册选图后裁剪正常不出现黑图弱网环境上传九张图的过程中断网再恢复点击重试能只重传失败项低内存机型连续选九张图压缩拍照不OOM国产ROM后台上传过程中退到后台再回来进度和状态不丢快速点击提交服务端只生成一条记录这些场景模拟器大多测不出来尤其是分区存储和相册Uri相关的问题强烈建议在真机上过一遍。5.2 抓包失败的原因与排查思路上线后如果用户反馈提交失败服务端又没看到请求日志第一件事就是抓包。但抓包失败往往不是服务端的问题而是客户端的坑。最典型的情况有两个一是用户手机开了代理但app没有配置代理。OkHttp默认跟随系统代理如果你的app内部把Proxy.NO_PROXY写死了那Charles和Fiddler无论怎么设置都抓不到包。这种情况我们后来统一做了一个调试开关仅在debug包开启“允许系统代理”的配置release包关闭避免用户用抓包工具分析生产环境流量。二是HTTPS证书校验。很多app做了SSL Pinning遇到自签证书直接握手失败抓包工具只会看到一个空连接。这种情况下先确认是证书校验失败还是DNS解析失败可以临时用network_security_config在debug模式下放行用户证书network-security-config base-config cleartextTrafficPermittedfalse trust-anchors certificates srcsystem / certificates srcuser / /trust-anchors /base-config /network-security-config注意release包不能这么配否则安全等级直接归零。我们上一个正式版本就是因为这个配置漏删被安全测试扫出来骂了一顿。5.3 自定义混淆字典的暗坑正则抓不到崩溃原因热词里有一条“android自定义混淆字典无效”这个我们恰好遇到过。项目用了自定义的proguard字典把类名和方法名混淆成a、b、c之类的短名崩溃日志里看到的堆栈全是字母。更烦人的是有些混淆字典里的字符跟厂商的崩溃采集系统有冲突导致堆栈解析失败直接显示“无法解析崩溃位置”。排查方式说穿了很简单确认proguard文件里确实写了-obfuscation-dictionary并且字典文件的编码必须是UTF-8无BOM如果字典里包含非法字符比如不可见控制字符R8会在打包时静默跳过那一条规则。我们那次就是因为在Windows上编辑字典文件保存成了带BOM的UTF-8第一行规则直接失效后面的规则全部没有生效。如果要给正在做这类功能的人一个建议不要为了混淆而混淆自定义字典并不能显著提升app安全性反而会给线上问题排查增加巨大成本。默认的类名混淆加上资源混淆已经足够大多数人使用。5.4 举报申诉数据的核对与对账最后补充一个很多人容易忽略的点运营后台跟客户端显示的数据必须能对上账。客户端显示“申诉被驳回”服务端查询结果却是“申诉通过”这种问题多半是客户端缓存了旧状态或者接口返回的字段被某个中间层改写了。我们的做法是服务端在下发状态时附带一个updatedAt时间戳客户端每次进入详情页都对比时间戳不一致就强制刷新。另外在列表页和详情页之间做了状态轮询详情页在前台时每三十秒拉一次最新状态保证用户看着页面等审核结果时能实时刷出来。这一套流程全部做完基本能覆盖举报申诉功能从用户提交到后台审核到结果通知再到争议复核的完整闭环。功能本身不难难的是把每一步的边界情况和异常场景都想到。我个人在实际开发中的体会是这类业务功能代码量不大但涉及的状态和路径都很多最值得多花时间的地方不是写代码本身而是把状态机、文件共享授权、上传容错这些“脏活儿”都提前梳理干净。等到线上用户开始使用的时候你会发现省下来的全是事故和加班。
阅读完成 · 觉得有帮助?
咨询建站