简介:这是一份基于Android Studio开发的职工考勤APP完整项目源码包,面向具备一定Android基础、希望将综合技能落地为真实考勤场景的移动开发学习者。项目覆盖员工信息管理、上下班考勤录入、异常记录、出勤统计、通知提醒与权限分级等核心模块,代码结构完整,可直接在Android Studio中打开运行,也可作为课程设计或毕业设计二次开发。资源共547个文件,压缩包大小约14.88MB,包含XML界面布局、Java业务源码、Gradle构建脚本及APK安装包等主要类型,整体工程配置清晰,便于按模块分段查阅。目前已有692人学习下载。借助该包可重点掌握SQLite数据库操作、Material Design组件使用、Notification通知机制及MPAndroidChart图表集成等关键技术,同时了解异常考勤统计与权限管理的完整实现思路,是一份兼顾功能演示与实践参考的优质Android项目。
1. 安卓职工考勤APP.7z:一个压缩包背后,装的是一套打卡与审批逻辑
拿到「安卓职工考勤APP.7z」这类压缩包时,第一件事不是急着找安装包,而是先解压看结构:它里面放的可能是完整的 Android Studio 工程,也可能是一个已经签名的 APK 加配套的签名文件和说明文档。如果只有 APK 没有工程,后续改打卡规则就得走逆向,成本直接上一个台阶;有工程文件,才谈得上按自己公司的考勤制度去调。这个 APP 的核心价值很简单:把「员工在哪儿、什么时候、用什么方式打卡」变成一条可信的记录,再往后接上迟到早退、请假审批和月度报表。适合接手这类项目的安卓开发、要给外包需求做验收的负责人,以及想用最少成本在企业内部跑通考勤系统的一线工程师。下面按「拆需求 → 定方案 → 跑通 → 避坑 → 交付」这条路径,讲一套可以直接照做的完整做法。
2. 先把打卡方式定死:三种主流方案怎么选,技术栈怎么定
考勤 APP 的所有功能都围绕「打卡」展开,打卡方式定不下来,后面的围栏判断、数据表设计、防作弊全是空中楼阁。这一章先把选型问题解决掉。
2.1 WiFi打卡、GPS打卡、扫码打卡:适用场景与坑位对比
常见的打卡方式就三种,各有各的适用边界:
- WiFi 打卡:读取当前手机连接的 WiFi 的 SSID/BSSID,与后台配置的「公司 WiFi」白名单比对,匹配即打卡成功。适合门店、办公室这类人员位置相对固定的场景,室内定位漂移影响不到它。
- GPS 打卡:通过定位 SDK 拿到经纬度,后端计算与公司坐标的距离,落在围栏半径内才算有效。适合外勤、巡检、工地等人员需要移动的场景。
- 扫码打卡:扫前台或会议室张贴的二维码完成打卡,本质是「在场证明」,适合临时打卡点、访客或会议室签到,不适合作为全员日常打卡手段。
三者可以混合用。我经手的项目里最稳的组合是:办公室默认 WiFi 打卡,外勤员工走 GPS 打卡,两种方式打出来的记录在数据库里用type字段区分,报表统计时互不干扰。不要把三种方式做成「哪个先触发算哪个」,否则员工在办公室连着 WiFi 同时开着定位,会打出两条不同来源的记录,对账时非常痛苦。
2.2 技术栈选型:原生 Android 和 uni-app 差在哪
技术栈选择取决于一个核心问题:这次交付要不要覆盖 iOS。如果领导明确说「先做安卓,iOS 后面再说」,我建议直接上原生 Android,Kotlin 为主。原因在于考勤 APP 绕不开定位保活、前台服务、系统权限适配这几件事,原生的LocationManager和前台服务 API 最直接,出问题好查。统计显示这类内部工具 APP 八成以上是纯安卓交付。
如果要求一套代码同时出安卓和 iOS,常见做法是选 uni-app 这类跨端框架。它自带plus.geolocation定位模块,写一次逻辑双端跑,界面用 Vue 语法就能写,出包速度快。代价也明显:后台定位策略受框架限制,厂商 ROM 的保活适配要做更多兼容;调底层传感器时还是得写原生插件绕一圈。所以决策准则我一般这么定:
- 纯安卓、功能以打卡和审批为主、后续可能要加人脸比对:原生。
- 双端都要、预算紧、工期短、定位精度要求不苛刻:uni-app。
- 已有现成 H5 考勤系统,只想套个壳:uni-app 的 WebView 方案优先,但放弃复杂的后台定位需求。
选型阶段最好把「防作弊」「异常申诉」这两项也纳入评估,它们决定了开发量是 2 周还是 2 个月。下面这张表可以直接拿去和同事对齐:
| 对比维度 | 原生 Android(Kotlin) | uni-app |
|---|---|---|
| 定位精度控制 | 高,直接调 LocationManager / FusedLocationProvider | 中,依赖 plus.geolocation 封装 |
| 后台保活与前台服务 | 灵活,可自控 | 受框架生命周期约束 |
| 双端复用 | 只出安卓 | 安卓 + iOS 一套代码 |
| 工期(单人) | 2~3 周 | 1~2 周 |
| 后期维护成本 | 低,代码可控 | 中,框架升级可能引入兼容问题 |
选完技术栈,下一步就是把最小打卡流程跑通。原生工程的可复制性最强,下面以原生 Android 为例,从建工程开始讲。
3. 把最小打卡流程跑通:工程权限、GPS定位与本地落库
这一章解决的是「代码怎么写」。一个能演示的考勤 APP 最少要覆盖三件事:拿到定位权限、获取经纬度、把打卡记录写进本地库。后台接口可以后面再接,这三步先跑通,整个 APP 的骨架就立住了。
3.1 新建工程与权限声明:Android 6 到 14 的权限写法差异
新建 Android Studio 工程时,包名建议直接命名为com.company.attendance这类和业务强相关的名字,不要用com.example,后面改包名会牵连签名和第三方 SDK。工程创建好后,第一件事是打开AndroidManifest.xml声明权限:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <!-- 定位权限:Android 6.0 以上需要运行时申请 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- 前台服务:Android 8.0 以上定位型服务必须声明 --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <!-- 网络权限:上报打卡数据和获取服务器时间 --> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <application android:label="职工考勤" android:theme="@style/Theme.Attendance"> <!-- 前台服务组件:定位保活要用 --> <service android:name=".service.LocationService" android:foregroundServiceType="location" /> </application> </manifest>权限声明里有三个容易被忽略的版本差异。第一,ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION在 Android 6.0(API 23)之后必须在运行时单独申请,不能只写在 Manifest 里。第二,Android 10(API 29)开始,后台定位需要额外申请ACCESS_BACKGROUND_LOCATION,但考勤打卡场景几乎不需要后台持续定位,只在用户按下打卡按钮时取一次位置即可,所以这个权限不申请更好,申请了反而触发应用市场审核的隐私合规问题。第三,Android 14(API 34)对前台服务类型做了强校验,如果 service 声明了foregroundServiceType="location",运行时必须真的调用startForeground并传入对应的类型,否则直接崩ForegroundServiceTypeNotAllowedException。
运行时权限请求的代码写法很固定,用ActivityResultContracts.RequestPermission是当前最干净的方案:
class CheckInActivity : AppCompatActivity() { private val locationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { startLocationService() } else { Toast.makeText(this, "定位权限被拒绝,无法完成打卡", Toast.LENGTH_LONG).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 检查是否已有权限,没有则发起请求 if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } else { startLocationService() } } private fun startLocationService() { // 启动前台定位服务,内部执行一次定位并回调结果 startForegroundService(Intent(this, LocationService::class.java)) } }这段逻辑说明几个关键点。registerForActivityResult是 AndroidX Activity 1.2.0 之后推荐的权限回调方式,替代了已经废弃的onRequestPermissionsResult,回调一定在主线程,可以安全刷新 UI。权限被拒绝后没有走「再次申请」的逻辑,这是故意的——考勤 APP 在用户明确拒绝后反复弹权限框,会被系统当成恶意应用,正确做法是提示用户去系统设置里手动开启。前台服务启动用startForegroundService而不是startService,这是 Android 8.0(API 26)之后的规定,前者要求服务在 5 秒内调用startForeground,否则抛RemoteServiceException,这是新手最容易翻车的地方之一。
3.2 实现GPS定位打卡:获取经纬度的代码与参数设定
打卡场景只需要「点下按钮时拿一个可信的位置」,不需要持续监听。所以定位服务里用LocationManager.requestSingleUpdate比requestLocationUpdates更合适——只回调一次,拿完即走,省电省心。
class LocationService : Service() { private lateinit var locationManager: LocationManager override fun onCreate() { super.onCreate() locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager startForeground( NOTIFICATION_ID, Notification.Builder(this, "location_channel") .setContentTitle("考勤定位中") .setContentText("正在获取当前位置用于打卡") .setSmallIcon(android.R.drawable.ic_menu_mylocation) .build() ) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // 设置定位请求参数,单次定位 val request = LocationRequest.Builder(1000L) .setQuality(LocationRequest.QUALITY_HIGH_ACCURACY) .build() if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { locationManager.getCurrentLocation( LocationManager.GPS_PROVIDER, null, mainExecutor, ::onLocationResult ) } return START_NOT_STICKY } private fun onLocationResult(location: Location) { val latitude = location.latitude val longitude = location.longitude val accuracy = location.accuracy // 这里把经纬度回传打卡页面,同时触发本地落库 saveCheckInRecord(latitude, longitude, accuracy) stopSelf() } }这里的参数设置有几个讲究。LocationRequest.Builder(1000L)的 1000L 表示最大等待时间 1 秒,超过 1 秒没拿到 GPS 结果就会尝试用网络定位结果兜底。QUALITY_HIGH_ACCURACY要求 GPS、WiFi、基站三者都参与定位,精度一般能到 10~30 米;如果改成QUALITY_BALANCED_POWER_ACCURACY,耗电会低一些,但室内精度可能掉到 100 米以上,打卡围栏就形同虚设。getCurrentLocation是 Android 12(API 31)新增的 API,比老的requestSingleUpdate好在不需要手动注册和注销监听器,拿不到结果时也不会挂在回调里,系统会自动清理。
一个重要的兜底逻辑:如果getCurrentLocation超时没有回调,很多实现就直接转getLastKnownLocation。这个做法只能当备用,不能当主路径——lastKnownLocation可能是几个小时前的缓存位置,员工在公司打卡却打出家里坐标,就是它干的。我的处理是:缓存位置仅在 accuracy 小于 200 米且时间戳在 5 分钟之内时才采用,否则提示用户移动到开阔区域重试。
3.3 考勤记录落库:SQLite最小表结构与查询
定位拿到之后,打卡记录必须先落本地,再异步上报服务器。这样即使断网,打卡动作也不会丢,后续补传就行。本地库用 SQLite 足够,不需要引入 GreenDAO 或 Room 这种重框架,单表 CRUD 用原生 API 写起来反而更直白。
-- 考勤记录表 CREATE TABLE IF NOT EXISTS checkin_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id TEXT NOT NULL, -- 员工工号 checkin_date TEXT NOT NULL, -- 打卡日期,格式 yyyy-MM-dd checkin_time TEXT NOT NULL, -- 打卡时间,格式 yyyy-MM-dd HH:mm:ss type INTEGER NOT NULL DEFAULT 0, -- 0=上班 1=下班 source INTEGER NOT NULL DEFAULT 0, -- 0=WiFi 1=GPS 2=扫码 latitude REAL, -- GPS纬度 longitude REAL, -- GPS经度 accuracy REAL, -- 定位精度,单位米 address TEXT, -- 逆地理编码地址 device_id TEXT, -- 设备标识,用于异常排查 status INTEGER NOT NULL DEFAULT 1, -- 1=有效 0=撤销 UNIQUE (employee_id, checkin_date, type) );表结构的关键设计在UNIQUE (employee_id, checkin_date, type)这条约束上。它保证了同一员工同一天只能有一条上班记录和一条下班记录,前端连点两次打卡按钮、网络重试导致的重复提交,都会被数据库直接挡掉。这个约束比在应用层做判断可靠得多,是防重复打卡的第一道防线,我从实战教训里总结出来的:早期没有加这个约束,测试阶段就出现过同一个人一天 17 条打卡记录的惨状。
插入数据时配合INSERT OR REPLACE使用,语义是「存在就替换,不存在就插入」:
INSERT OR REPLACE INTO checkin_record (employee_id, checkin_date, checkin_time, type, source, latitude, longitude, accuracy, address, device_id, status) VALUES ('EMP001', '2024-11-20', '2024-11-20 09:02:15', 0, 1, 31.2304, 121.4737, 12.5, 'XX路XX号', 'device_sn_123', 1);参数说明:source字段很重要,报表统计时要按来源分开核对,WiFi 打卡和 GPS 打卡的围栏规则不同,混在一起做分析会得出错误结论。accuracy字段也别省,它是判断这次打卡是否可信的原始依据,后面做围栏判定时要和公司坐标的距离一起参与运算。device_id用于发现「一台设备给五个人打卡」的作弊行为,虽然不能完全杜绝,但至少能给到排查线索。
落库完成,最小打卡闭环就成立了一半。另一半是「怎么判定这次打卡在不在公司范围内」,这涉及经纬度计算和围栏设计,是考勤 APP 里最容易出逻辑漏洞的地方,下一章专门拆开讲。
4. 打卡距离与围栏判定:经纬度换算不能只靠直觉
打卡页面显示「定位成功」和「判定打卡有效」之间隔着一整个计算层。很多半路出家的实现直接拿两个经纬度做绝对差值判断,或者用地图 SDK 的distanceBetween却没有正确设置坐标点,结果就是员工明明在公司楼下却打不上卡。这一章把计算和判定写明白。
4.1 Haversine公式:距离误差从哪来
算两个经纬度点之间的距离,工程上最常用的不是 Vincenty 算法那种高精度模型,而是 Haversine 公式,它把地球当成半径 6371 公里的球体来处理,在公里级距离上误差可以忽略,代码也好写:
import math def haversine_distance(lat1, lng1, lat2, lng2): # 地球半径,单位:公里 R = 6371.0 # 角度转弧度 phi1 = math.radians(lat1) phi2 = math.radians(lat2) delta_phi = math.radians(lat2 - lat1) delta_lambda = math.radians(lng2 - lng1) # Haversine 核心公式 a = math.sin(delta_phi / 2) ** 2 + \ math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) # 返回公里数,乘 1000 换成米 return R * c * 1000 # 示例:公司坐标 (31.2304, 121.4737),员工定位 (31.2310, 121.4740) distance = haversine_distance(31.2304, 121.4737, 31.2310, 121.4740) print(f"距离: {distance:.1f} 米")参数说明:R = 6371.0是地球平均半径,如果追求再精确一点可以用 6371.0088,但对考勤围栏这种几十到几百米的判定场景,0.0088 公里的差别完全无感。math.radians把十进制度数转成弧度,这个是新手最容易忘的,忘掉的话结果会偏差几十倍。两点距离在 1 公里以内时,Haversine 和 Vincenty 的差别通常在毫米级,完全可以忽略;只有当两点跨越大几个城市做「最近的考勤点匹配」时,才会建议升级算法。
有一个比公式更值得注意的坑:很多在线地图 SDK 提供的是 GCJ-02 坐标系,而LocationManager拿到的是 WGS-84 原始坐标,两种坐标系在同一个城市偏移约 300~500 米。如果公司坐标是从地图上拾取的 GCJ-02 点,员工定位是 WGS-84,直接套 Haversine 公式会得出「员工在 300 米外」的结论,围栏设再大都没用。正确做法是统一坐标系:要么公司坐标也用官方定位 SDK 打一次点取 WGS-84,要么在后端做坐标转换。我的习惯是公司坐标用「现场拿同一台测试机定位三次取平均」,保证员工端和公司端坐标系完全一致。
4.2 围栏半径与防作弊:50米还是200米,偏移阈值怎么设
围栏半径的设定是考勤规则里最敏感的参数,没有之一。设小了员工天天投诉打不上卡,设大了打卡形同虚设。我踩过一轮坑后的经验值:
| 场所类型 | 推荐围栏半径 | 理由 |
|---|---|---|
| 写字楼办公室(1~3层室内) | 150~200 米 | 室内 GPS 漂移 30~80 米很常见,再叠加坐标系偏移,100 米以下会让部分员工无法打卡 |
| 园区/工厂(室外为主) | 100~150 米 | 室外 GPS 精度较好,但大型厂区边界不规整 |
| 门店/商铺 | 80~100 米 | 门店密集区域,半径过大容易和隔壁店混打 |
| 工地/外勤点位 | 200~300 米 | 外勤点位往往无明确门牌,需要更大容错空间 |
围栏判定逻辑本身很直白:用 Haversine 算出员工定位到公司坐标的距离,小于等于围栏半径则通过。但防作弊不能只靠这一个条件,常见的作弊手段和对应的检测方法:
- 模拟定位:Android 开发者选项里可以伪造位置。检测手段是检查
location.isFromMockProvider,API 31 之后模拟位置信息被收进Location的isMock标记,读取后置为无效打卡。 - 缓存定位:拿到的是几小时前的旧位置,因为 accuracy 看着正常,距离也在围栏内就放行了。检测手段是检查定位时间戳,超过 5 分钟直接拒绝。
- 坐标偏移注入:通过 Root 设备改定位。单靠客户端很难完全封死,常见做法是连续取 3 次定位,每次间隔 5 秒,偏差超过 50 米就判异常,让用户重试。
中间还有一个伪造和真实的灰色地带必须提醒:室内定位在电梯、地下停车场会出现「假漂移」,员工明明在工位,定位却显示公司在隔壁楼。这种情况不能简单归为作弊,否则员工体验会很差。我的处理方案是:连续两次定位都落在围栏外且在 500 米内,才提示「定位偏差过大,请确认 GPS 信号良好」,给员工一个重新打卡的机会。
4.3 迟到早退判定:把时间规则做成状态机
打卡通过围栏校验只是第一步,考勤结果还取决于时间规则的判定。时间比较这块必须统一用服务器时间,不能信手机本地时间,道理很简单:手机时间用户可以改,改了就能把迟到的打卡时间伪造成准点。
判定逻辑可以抽象成一个状态机,每天每位员工的考勤状态从「未打卡」开始,按打卡事件转移:
| 状态 | 触发条件 | 转移结果 |
|---|---|---|
| 未打卡 | 上班时间前完成打卡 | 正常上班 |
| 未打卡 | 上班时间后 5 分钟内完成打卡 | 迟到(宽容期内可申诉) |
| 未打卡 | 超过宽容期才打卡 | 迟到 |
| 正常上班 | 下班时间后完成打卡 | 正常下班 |
| 正常上班 | 下班时间前完成打卡 | 早退 |
| 迟到/正常 | 全天只有一次打卡 | 当日缺卡 |
具体实现上,后端只需要把「上班截止时间」「下班开始时间」「宽容分钟数」做成可配置项,每次打卡事件进来算一次状态转移,结果落库到attendance_result表。这里最容易被忽略的是节假日规则:不要自己维护节假日表,直接对接国家的节假日接口,或者让行政在后台系统里导入。自己维护一份节假日表,大概率会在某个调休周末漏掉,然后一整月考勤数据全错,这种教训一次就够。
状态机跑通后,考勤核心链路就完整了:定位 → 落库 → 距离判定 → 时间判定 → 生成考勤结果。到这里功能层面能演示,但真实环境里有一堆比功能更难缠的问题,下一章集中排雷。
5. 考勤APP避坑指南:定位、权限、保活与多ROM的5个坑
考勤 APP 是典型的「功能简单、环境复杂」项目。代码量不大,但真机上的问题各有各的玄学,这里挑 5 个出现频率最高、影响面最大的坑,按「现象 → 原因 → 解决」的套路记录,都是可以直接抄作业的排查路径。
5.1 打卡页一直转圈,定位结果就是不回来
现象:在室内点打卡按钮,页面持续显示「定位中」,一分钟过去还是没有结果,室外一切正常。
原因:LocationManager在室内收不到足够的 GPS 卫星信号,而代码里只注册了GPS_PROVIDER,没有启用网络定位兜底。GPS 在室内、电梯、地下车库穿透力差,这是物理限制,不是代码 bug。
解决:定位请求里同时注册GPS_PROVIDER和NETWORK_PROVIDER,让系统按可用性自动选择;同时设置 10~15 秒的定位超时,超时后主动提示「当前环境 GPS 信号弱,请靠近窗边或使用 WiFi 打卡」。不要无限制等下去,考勤场景用户没有耐心,也没有必要为了一个打卡等 30 秒。这个坑在第一版上线后让半个办公室的人打不上卡,就是因为只认 GPS 打死不兜底。
5.2 华为、小米后台运行一段时间后,打卡直接失败
现象:APP 切到后台几个小时后,再打开时定位服务已经死了,打卡提示「定位服务未运行」。
原因:国产 ROM 的省电策略会对长时间后台运行的应用做冻结。华为的「启动管理」、小米的「神隐模式」、OPPO/vivo 的「待机优化」,都会主动杀掉没有在白名单里的第三方应用。这和 APP 本身逻辑无关,是厂商级的行为。
解决:三层配合。第一,前台服务常驻并配常驻通知,降低被系统清理的概率;第二,引导用户在 ROM 设置里把 APP 加入「不受限制」或「允许后台运行」的白名单;第三,打卡逻辑本身做成「按需启动」,用户进入打卡页面时动态拉起定位服务,而不是依赖后台长跑。第三点才是我真正推荐的方案——考勤打卡本来就应该是前台主动行为,别和 ROM 的省电策略硬刚,顺着系统的规则做,反而最稳。
5.3 同一员工同一天打出两条上班卡
现象:员工反馈自己只打了一次卡,后台却查到两条上班记录,一条 09:01,一条 09:02。
原因:网络慢时用户手抖多点了一次按钮;或者 APP 端上报请求超时后自动重试,结果第一次请求其实已经到服务器了,只是响应丢了。接口层面没有做幂等,数据库层面没有唯一约束,两条记录就都落库了。
解决:上两把锁。第一把锁在数据库层,就是我前面讲的UNIQUE (employee_id, checkin_date, type),保证物理上不可能出现同一天同类型的两条记录;第二把锁在接口层,客户端生成一个request_id(UUID),服务端用request_id去重,同一个request_id的请求只处理一次。两把锁缺一不可,数据库锁防的是漏网记录,接口幂等防的是重复请求。
5.4 手机时间改慢 10 分钟,打卡系统就判定准点
现象:测试阶段发现,把手机系统时间调慢后,迟到的人也能打出「准点卡」。
原因:客户端拿System.currentTimeMillis()作为打卡时间上报,而后端没有校验这个时间,直接把它当成打卡时间存进库。手机时间完全可控,时间戳天然不可信。
解决:打卡时间的唯一权威来源是服务器时间。客户端只上报「打卡动作发生」,服务端在收到请求那一刻用自己的时钟生成checkin_time。如果客户端需要显示打卡时间,可以先通过一个时间同步接口拿到服务器时间和本地时间的差值,展示时加上这个偏移;但服务端写入数据库时,永远只认自己的时间。这个坑在早期版本翻车过,之后我再也没让任何客户端时间戳进入过考勤结果表。
5.5 抓包工具看得到请求,APP 里却报网络错误
现象:开发调试时,用抓包工具能正常看到 APP 发出的请求和响应,但 APP 内部一直提示网络异常,日志里报 SSL 错误。
原因:Android 7.0(API 24)之后,系统默认不信任用户级别的 CA 证书。抓包工具(如 Charles、mitmproxy 之类)安装的是用户证书,APP 的OkHttp默认只信任系统证书,两者对不上,握手直接失败。
解决:调试阶段在res/xml/network_security_config.xml里显式放开用户证书信任:
<network-security-config> <domain-config cleartextTrafficPermitted="false"> <domain includeSubdomains="true">api.example.com</domain> <!-- debug 包允许信任用户证书,release 包不配置此项 --> <trust-anchors> <certificates src="system" /> <certificates src="user" /> </trust-anchors> </domain-config> </network-security-config>参数说明:domain节点里的api.example.com要换成自己后端接口的真实域名,只对这个域名放开证书信任,不要写全放行的配置,否则上线后会有中间人攻击风险。更规范的做法是让build.gradle里的 debug 构建和 release 构建使用不同的network_security_config文件,release 包不包含trust-anchors的 user 证书信任配置。这是工程化层面的正统解法,既保证调试效率,又不牺牲生产环境的安全边界。
6. 从工程到7z交付包:签名验证、机型适配与上线后的检查清单
功能全部调试完成后,最后一步是把工程或 APK 打成 7z 交付出去。打包前先做一次 clean 构建,把 debug 签名替换成正式的 release 签名。用apksigner验证签名是否有效,命令行是apksigner verify --print-certs app-release.apk,能看到证书的 SHA-256 指纹才算签名成功。这里提醒一句:签名用的 keystore 文件一定要备份并记录密码,丢失之后 APP 只能换包名重新上架,用户数据全部归零,这种后悔药是没有的。
工程文件打 7z 时,要把build/、.gradle/、.idea/这类本地生成目录排除掉,只保留源码、资源和 Gradle Wrapper,这样对方解压后直接能编译:
7z a attendance_app.7z ./project \ -xr!build -xr!.gradle -xr!.idea \ -xr!local.properties -xr!*.iml参数说明:-xr!表示递归排除指定目录或模式,排除local.properties是因为里面含本机 SDK 路径,其他人拿到会直接编译报错;保留gradlew和gradle/wrapper/是为了让接手的人用固定版本构建,不会因为 Gradle 版本不一致而踩坑。对 APK 的分发,也要把versionCode和versionName写在变更说明里,内测时靠版本号定位问题。
上线后的验证我习惯固定在发版清单里:一台不插 SIM 卡的真机跑 GPS 打卡,一台只连 WiFi 的机器跑 WiFi 打卡,再加一台安卓虚拟平台跑低版本系统兼容性检查。真机上用adb shell dumpsys location确认定位 provider 状态、adb logcat抓崩溃日志;模拟器上如果访问不了内网打卡接口,检查网络连接方式,改成桥接模式就能通到内网。这套检查做完,再考虑推给全员使用。希望帮到你。
本文还有配套的精品资源,点击获取