
简介这是一份面向安卓与iOS多端游戏开发者的防沉迷系统SDK资源支持快速集成到Unity项目适合作为高校毕业设计、课程设计或工程实训选题。该SDK覆盖实名认证、时长限制、时段控制等核心防沉迷逻辑并自带可直接调用的核心模块开发者无需从零搭建即可接入游戏能省去大量重复开发工作。压缩包共331个文件总大小约10.8MB主要包含Swift、Java、Objective-C等原生代码以及Unity工程设置、安卓构建脚本、iOS工程配置、PNG图片和JSON数据文件整体结构清晰可方便按平台查阅所需内容。目前已有48人学习下载对研究防沉迷系统的整体架构、认证流程和多端交互方式有较好的参考价值。项目中还包含完整的工程配置与多平台目录适合作为毕业设计演示项目或上线前的合规预研基础代码经过测试可直接运行遇到问题可与博主交流。1. 防沉迷 SDK 不是玩具先搞懂它为什么值得做我帮人看毕设项目时最怕看到“防沉迷系统”只做了一个登录页加一个倒计时。真正的防沉迷系统要同时回答四个问题你是谁、能玩多久、什么时候不能玩、提醒你下线的时候怎么弹窗才不砸手机。这套“Android毕设实战项目 手机游戏防沉迷系统 SDK”正好把这些都打包好了客户端覆盖 Android、iOS、Unity目标是快速接入不是让你从零研究一个月。如果你正在找毕设方向或者想给个人游戏补上合规能力这个资源值得你腾一个晚上完整拆一遍。2. 从全局到接入SDK 的目录设计与三端初始化拿这套资源的时候先别急着往工程里拖文件。我习惯先把包拆开看清楚每个目录是干什么的再去碰代码。很多接入翻车不是因为你不会写代码而是因为不知道某个文件应该放在哪个工程、哪个构建阶段被加载。2.1 先拆包SDK 包里到底有什么这类移动端 SDK 压缩包通常会按平台分目录我见过的大致结构如下你可以对照自己手里的包核对目录/文件作用接入时怎么处理android-sdk/Android Library 工程或 AAR 包作为 module 导入或放到 libs 目录ios-sdk/iOS Framework 或源码拖进 Xcode 工程配置搜索路径unity-plugin/Unity 的 Plugins/Android 与 Plugins/iOS 封装直接拷贝到 Assets 目录demo/演示工程或示例场景先跑通 demo再迁移到你自己的工程docs/接入文档、常见问题、接口说明先读一遍重点看版本要求和权限说明我一般会先把 demo 工程跑起来而不是直接把 SDK 往自己的项目里塞。跑通 demo 的过程能暴露 80% 的环境问题比如 Android SDK 版本不匹配、Xcode 最低版本不够、Unity 的 API Compatibility Level 对不上。等你确认 demo 三端都能跑再动手迁移后面会省很多时间。这里还要留意一点不同压缩包里 android-sdk 的交付形式可能不一样有的是源码 module有的是预编译 AAR。源码 module 的好处是你后期调试可以直接进函数AAR 的好处是干净、不容易被误改。毕设场景我更推荐源码 module因为答辩时候老师喜欢追问内部实现手里有源码能讲得更细。2.2 Android 端Gradle 配置与初始化时机Android 端接入的核心动作有两个把 SDK module 挂进工程然后在 Application 里做一次初始化。先在根目录的 settings.gradle 里把 module 包含进来include :app, :antisystem然后在 app 模块的 build.gradle 里声明依赖dependencies { implementation project(:antisystem) }这里用 project 方式引用适合源码 module如果你的包是 AAR就把文件放进 libs 目录然后用implementation files(libs/antisystem.aar)。两种做法本质一样区别在于支持不支持源码级调试。初始化代码建议放在 Application 的 onCreate 里保证整个应用启动后只调一次public class GameApplication extends Application { Override public void onCreate() { super.onCreate(); AntiAddictionSDK.init( this, 你的AppId, new AntiConfig.Builder() .setDebug(BuildConfig.DEBUG) .setResetHour(5) .setUseServerTime(false) .build() ); } }这段代码里有几个参数需要解释清楚。AppId是 SDK 用来识别产品的唯一标识一般由接入方在后台申请如果你只是做毕设跑通流程可以直接用 demo 里的占位值。setResetHour(5)表示每日时长在早上 5 点刷新这是游戏行业很常见的刷新点因为凌晨 0 点到 5 点之间的玩家往往还在跨天直接按 0 点切日会让当天时长计算很混乱。setUseServerTime(false)表示先用本地时间跑逻辑这个配置只适合开发和演示线上必须改成 true否则用户改系统时间就能绕过时长限制。还有一个容易被忽略的点初始化回调默认跑在主线程。如果你在子线程里调用 init部分机型上回调会丢失或延迟。原因很简单SDK 内部可能依赖 Handler 和 Looper子线程没有 Looper 时消息发不出去。所以就算你从别的地方触发初始化也要先 post 到主线程。2.3 iOS 端framework 导入与 Info.plist 配置iOS 端接入比 Android 稍微繁琐一点因为 Xcode 工程的打包流程没有 Gradle 那么统一。先把 ios-sdk 里的 framework 或者静态库拖进工程然后在 Build Settings 里给 Other Linker Flags 加上-ObjC。这个参数很关键缺了它SDK 里的 Category 方法可能没有被链接进最终二进制运行时不报错但方法调用没反应这种问题排查起来非常折腾。初始化放在 application didFinishLaunching 里#import AntiAddictionSDK/AntiAddictionSDK.h - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSDictionary *config { resetHour: 5, debug: NO }; [AntiAddictionSDK startWithAppId:你的AppId config:config]; return YES; }这里的 config 参数与 Android 端的 AntiConfig 一一对应resetHour同样表示每日刷新时间点。iOS 端初始化没有回调SDK 会把状态变更统一交给 delegate 处理所以你要在启动后尽快设置[AntiAddictionSDK sharedInstance].delegate self;不然时长耗尽事件发生时会找不到代理。Info.plist 里通常要额外确认两项网络权限描述和本地网络权限。前者是常规配置后者是因为部分 SDK 的 debug 模块需要扫描局域网设备做日志透传。如果你只接生产环境可以不开本地网络权限但如果 SDK 文档里明确要求开启就一定要填NSLocalNetworkUsageDescription的说明文字否则 iOS 14 以上真机直接弹窗一闪而过。真机调试时还要注意iOS 16 之后新设备默认需要开启“开发者模式”。不开启的话Xcode 可以安装 App但控制台看不到 SDK 日志甚至断点不命中很多人会误以为是 SDK 没接入成功。这个和工程配置无关属于系统侧限制。2.4 Unity 端把两套原生的差异封成同一个 C# 接口Unity 端接入的核心工作量不在业务逻辑而在平台适配。同一个 C# 方法在 Android 上要反射 Java 类在 iOS 上要调用原生 C 接口。我习惯先写一个 Bridge 类把所有平台差异关在里面这样游戏业务层只认识一个接口using UnityEngine; public class AntiAddictionBridge { private static bool initialized; public static void Init(string appId) { if (initialized) return; initialized true; #if UNITY_ANDROID !UNITY_EDITOR using (var player new AndroidJavaObject(com.unity3d.player.UnityPlayer)) { var activity player.GetStaticAndroidJavaObject(currentActivity); using (var sdkClass new AndroidJavaObject(com.example.antisystem.AntiAddictionSDK)) { sdkClass.CallStatic(init, activity, appId); } } #elif UNITY_IOS !UNITY_EDITOR AntiAddictionBridge_iOS.iOSInit(appId); #endif } }这里的核心写法是#if UNITY_ANDROID !UNITY_EDITOR。后面那段!UNITY_EDITOR很多人会省略结果在编辑器里跑的时候就报 AndroidJavaObject 找不到类其实那只是编辑器环境没有真机 Java 类导致的加上条件编译就可以避免。静态字段initialized也必须保留。Unity 的场景切换默认会销毁普通对象而 SDK 初始化是全局性的你不能在每次加载场景时都重新初始化一遍。关于这一点第四章的避坑里我还会单独说。iOS 端的封装一般是再放一个原生文件挂到 Xcode 工程里通过 C# 的[DllImport(__Internal)]调用做法上不算复杂但记得正确设置Framework Dependencies。2.5 快速接入三件套AppId、包名、签名哪怕你把上面的代码都写对了还有三个信息必须保持一致否则接入后照样跑不通AppId、包名、签名。平台必填信息不一致时的表现AndroidapplicationId、签名证书初始化返回错误码回调一直不触发iOSBundle Identifier、AppId真机无法绑定用户时长上报丢失UnityAndroid/iOS 分包配置Build 时无法选到目标平台我见过最典型的场景是别人给的 demo 用的是包名com.example.game你换了自己的包名但忘了把测试服上的 AppId 一并换掉。SDK 内部用 AppId 去匹配包名两边对不上就拒绝注册。解决方法是先把 demo 原样跑通再一步一步改包名和签名每改一步就验证一次不要一次性全换。签名不一致在 debug 和 release 上表现还不一样release 更容易直接闪退。作为毕设项目建议先统一用 debug 签名跑通等最后打包上线前再切正式签名。3. 核心模块拆解实名认证、时长累计、宵禁与弹窗机制三端接入只是第一步真正决定这个防沉迷 SDK 含金量的是内部四个模块实名认证、每日时长累计、宵禁判定、游戏内弹窗。这四件事看起来各自独立实际上互相有依赖关系。实名认证的结果决定你进入哪条时长曲线宵禁判定要先看当前时间是否落在限制区间弹窗则由时长和宵禁两个条件共同触发。下面我按模块拆开讲。3.1 实名认证本地校验还是服务端校验实名认证是整个防沉迷系统的闸门。用户在进入游戏前需要输入姓名和身份证号SDK 先做格式校验再做成人/未成年判定。格式校验一般用身份证 18 位编码规则最后一位可能是数字或 X。下面这段加权因子校验是同类项目里很常见的实现public static boolean checkIdCard(String id) { if (id null || id.length() ! 18) return false; int[] weights {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; char[] codes {1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2}; int sum 0; for (int i 0; i 17; i) { char c id.charAt(i); if (c 0 || c 9) return false; sum (c - 0) * weights[i]; } return codes[sum % 11] Character.toUpperCase(id.charAt(17)); }这段代码的运算原理不复杂前 17 位分别乘以不同的权重然后对 11 取余用余数到映射表里找校验码。weights数组是固定常量顺序不能改改了之后所有合法身份证都可能被误杀。Character.toUpperCase(id.charAt(17))用于兼容用户输入小写 x 的情况这种细节在体验上很重要。需要强调本地校验只能证明“身份证号格式合法”不能证明“用户是本人”。真正的防沉迷合规要求服务端调实名认证接口拿到权威结果后才能确定账号状态。在这个毕设 SDK 里通常的做法是本地先做一次格式过滤再把数据提交到服务端做鉴权服务端返回adult、minor、unknown三种状态之一。如果你是做单机演示至少要预留这个接口抽象别把本地校验当成完整方案答辩时老师一定会追问这一点。3.2 每日时长累计本地存储与跨端同步实名认证通过后系统开始给用户累计在线时长。时长的计算不是简单地在计时器上加数字它要处理跨天重置、后台挂起、多端登录三个问题。先看本地累计的实现思路public class PlayTimeRecorder { private static final String KEY_DATE play_date; private static final String KEY_TOTAL play_total_minutes; private static final long RESET_HOUR 5; public static synchronized int getTodayMinutes(Context context) { SharedPreferences sp context.getSharedPreferences(anti, Context.MODE_PRIVATE); String today LocalDate.now().toString(); String savedDate sp.getString(KEY_DATE, ); if (!today.equals(savedDate) !isBeforeResetHour()) { sp.edit().putString(KEY_DATE, today).putInt(KEY_TOTAL, 0).apply(); } return sp.getInt(KEY_TOTAL, 0); } }这里的关键是isBeforeResetHour()方法。假设重置点是早上 5 点那么在凌晨 0 点到 5 点之间进入游戏日期虽然已经变成新的一天但业务上仍然属于“前一天”的时长周期。简单用日期字符串判断会漏掉这种情况导致玩家在凌晨 4 点 59 分时突然被清零体验很差。SDK 里通常会提供一个rolloverIfNeeded方法在每次读取时长的开始做一次日期翻转判断。本地存储只是地基。如果游戏支持 Android、iOS、Unity 三端登录同一个账号本地记录是没法跨端累计的。常见做法是服务端保存用户今日总时长客户端每次登录拉一次本地再按增量上报。上报可以做成每 30 秒或每分钟一次避免频繁请求把服务器打爆。上限判定要以服务端数据为准本地数据只用于 UI 展示防止用户关掉网络后无限续玩。3.3 宵禁时段判定22 点到 8 点不是唯一规则宵禁判定听起来就是“晚上 10 点到早上 8 点不能玩”但实际实现起来要注意跨天和节假日。先写一个最直接的判定方法public static boolean isCurfew(Calendar cal) { int hour cal.get(Calendar.HOUR_OF_DAY); return hour 22 || hour 8; }这段代码用hour 22 || hour 8来表达跨天晚上 10 点到夜里 12 点是第一个区段凌晨 0 点到早上 8 点是第二个区段。如果只写hour 22就会把凌晨 1 点漏掉。Calendar.HOUR_OF_DAY是 24 小时制不要用HOUR那是 12 小时制会产生上午/下午混淆。但宵禁不只是判断当前时点。很多游戏会把“节假日和周末”单独特判比如未成年人周五、周六、周日晚上 8 点到 9 点可以玩 1 小时其余日期 22 点到次日 8 点完全禁入。这种规则不能全部硬编码在客户端里因为假期安排每年都在变所以我一般建议把“今日可玩时间段”做成服务端下发的配置客户端只做解析和判定。SDK 里如果留了onReceiveDailyConfig这类回调优先把它用起来。实际游戏里还有一种常见做法不是直接禁止登录而是在进入游戏前做一次“宵禁阻断”。如果当前时间落在宵禁区间客户端弹一个“当前为未成年人宵禁时段”的提示并把玩家挡在登录流程外。这个场景和第四章要讲的弹窗机制是联动的它不只是 UI 层的事而是要在登录状态机里提前拦掉。3.4 游戏内弹窗弹窗的优先级与场景保护弹窗是防沉迷系统里最有“体感”的部分也是最容易写出 Bug 的部分。玩家打到一半突然被强制下线如果处理得粗暴连结算界面都会被顶掉存档都可能损坏。所以 SDK 会区分“提醒”和“强制下线”两种弹窗并让它们按照状态机切换。我拆过的这个 SDK 里弹窗触发逻辑是这样的当剩余时长不足 5 分钟时收到onRemind回调当剩余时长归零时收到onForceOffline回调。Unity 端一般在收到回调后由游戏侧决定弹出哪个面板。事件类型可以先定义成枚举public enum AntiEventType { None, Remind, ForceOffline, Curfew, AuthFinish }这里Remind和ForceOffline是两种完全不同的行为。收到Remind时只弹提示玩家还可以继续操作收到ForceOffline时则要进入阻断状态不能再发起点券或开始新对局。弹窗的优先级也很重要如果同时触发了宵禁和强制下线只展示一个阻断面板不要叠两个对话框。我会在弹窗入口处加一个当前状态判断同一时间只允许一个模态弹窗存在。Unity 端的弹窗载体建议做成一个常驻 Canvas配合DontDestroyOnLoad使用。很多新手把弹窗预制体挂在场景里的某个 UI 对象上结果场景一切换预制体被销毁回调来了找不到对象。游戏是场景频繁切换的地方弹窗载体必须独立于场景存在否则就会出现“系统提示我该下线了但游戏里什么都没发生”的诡异问题。4. 接入与跑测避坑五个最容易翻车的现场和排查方法这章的每一节都是我在实际接 SDK 时踩过或帮别人排查过的现场。接入文档写得再清楚也挡不住真机环境千奇百怪下面五条你如果提前预防能少熬好几个夜。4.1 现象Android 端初始化后回调一直不触发你有没有遇到过这种情况日志里能看到 SDK 初始化成功但是登录成功、时长到期这些回调一个都不进界面完全没反应。我最初以为是 AppId 配错了查了半天发现初始化在子线程里执行了。SDK 内部的回调是发到主线程 Handler 的子线程里调 init 时主线程 Looper 还没准备好或者消息被扔进了子线程队列回调自然丢失。正确做法是把初始化强制放到主线程new Handler(Looper.getMainLooper()).post(new Runnable() { Override public void run() { AntiAddictionSDK.init(context, appId, config); } });后面如果你还遇到回调不触发按这三个顺序查第一看初始化线程第二看 AppId 和包名是否匹配第三看是否在 demo 的 Application 里手动调了二次初始化。二次初始化会覆盖第一次注册的 listener等于之前的回调白设了。4.2 现象iOS 切后台再回来剩余时长没有减少游戏切后台再恢复按道理这段时间也应该算进游戏时长。但如果你用普通定时器计时iOS 在应用进入后台后会很快挂起定时器甚至被系统杀掉。等你回到前台定时器还是离开时的状态时长一分没扣。SDK 内部一般会提供暂停和恢复的接口你需要在这两个系统回调里调用- (void)applicationDidEnterBackground:(UIApplication *)application { [AntiAddictionSDK pauseTimeRecord]; } - (void)applicationWillEnterForeground:(UIApplication *)application { [AntiAddictionSDK resumeTimeRecord]; }pauseTimeRecord负责把当前时间戳记录到本地resumeTimeRecord会在回到前台时用当前时间减去上次记录时间把时间差补计进总时长。这里要注意补计是“恢复时一次性结算”不是恢复后慢慢累加。如果你接入时发现后台挂得越久恢复后时长跳得越多那说明 SDK 内部已经做到了补算是正常现象。怕就怕你没调这两个接口时间差根本没进累计逻辑。4.3 现象Unity 场景切换后 SDK 重复初始化直接闪退Unity 工程里我见过有人把初始化代码放在某个 MonoBehaviour 的 Awake 里结果场景一切换SDK 又初始化了一遍轻则回调重复触发重则直接闪退。原因很简单Unity 场景切换时之前的游戏对象被销毁新场景里的对象又执行了一次 AwakeSDK 内部单例被重置了。解决方法是加一个进程级的静态标记public class AntiAddictionBridge { private static bool initialized; public static void Init(string appId) { if (initialized) return; initialized true; // 真正的初始化逻辑 } }这里有一个细节要注意不要把initialized存到 PlayerPrefs 里。PlayerPrefs 是持久化存储App 冷启动后如果你读到了 true 就不去初始化后果是这个进程里所有 SDK 功能全部失效。static bool只存在于当前进程的生命周期内既能防止单场景重复初始化又不会影响下次冷启动。结合 2.4 节的条件编译这套写法在真机和编辑器里都能安全运行。4.4 现象把系统时间改到昨天防沉迷立刻失效如果 SDK 使用本地系统时间来判断时长和宵禁玩家一键把手机时间调回前一天当日时长就被清零又能继续玩。这不是 SDK 的 bug而是设计上没做时间可信度校验。最简单的防线是引入服务器时间。每次启动时向服务端请求一次当前时间戳之后所有判定都基于这个时间本地时间只用来计算偏差。偏差超阈值时直接拦截long serverTime getServerTime(); long skew Math.abs(serverTime - System.currentTimeMillis()); if (skew 5 * 60 * 1000L) { // 提示用户校准系统时间不进入游戏 }5 * 60 * 1000L是 5 分钟阈值你可以根据产品要求调整。阈值太大会给改时间留出空间太小又可能在普通用户手机时间稍微不准时误伤。我一般先用 5 分钟等上线看真实设备分布再收紧。如果你做的是单机演示没有服务端时间接口至少也要在本地存一个上一次可信时间一旦发现时间回拨就触发一次强制校验。4.5 现象Android 模拟器集成时提示找不到 soiOS 模拟器也报错很多毕设同学喜欢用模拟器跑测试结果 Android 端报dlopen failed: library libanti.so not foundiOS 模拟器也找不到 framework 或者链接失败。这大多不是代码问题而是架构不匹配。如果 Android SDK 只内置了armeabi-v7a和arm64-v8a的 so你的 x86_64 模拟器就无法加载。不要硬改 SDK先用unzip -l aar文件看下jniLibs目录下有哪些 ABI再在 build.gradle 里加限制splits { abi { enable true reset() include armeabi-v7a, arm64-v8a } }这段配置的作用是只打包 SDK 支持的 ABI避免把不兼容的 x86 so 混进安装包。但更省心的做法是Android 端直接用真机调试iOS 端优先用真机。模拟器在定位性能和系统 API 差异时有用但在验证 SDK 这类强依赖真机能力的功能时真机是唯一的可靠环境。iOS 16 以上模拟器还要单独配置架构经常出现“模拟器编了但原生库只支持 arm64”的情况与其浪费时间在架构上不如一开始就用真机跑。5. 再进一步造一个调试工具把防沉迷逻辑调明白接入跑通之后别急着收工。你需要一个能模拟各种边界状态的调试工具把这个 SDK 的触发逻辑真正调明白。我一般会在工程里加一个 Debug Overlay通过开发者指令直接改写剩余时长验证系统在关键时刻的表现。void InjectRemainTime(int minutes) { #if UNITY_EDITOR || DEBUG PlayerPrefs.SetInt(mock_remain_seconds, minutes * 60); AntiAddictionBridge.Refresh(); #endif }这段代码只允许在 Debug 包和编辑器里生效发布包的编译符号不会包含DEBUG所以玩家无法利用这个入口做修改。AntiAddictionBridge.Refresh()是主动让 SDK 重新读取剩余时间的接口如果 SDK 没有暴露这个接口你可以通过重新绑定回调或者请求一次状态来触发刷新。它不能替代真实的计时逻辑但能帮你把“剩余 5 分钟”和“剩余 0 秒”这两个临界状态稳定复现。我会按下面这张清单来验收一套接入是否合格测试用例预期结果未实名账号登录进入实名认证流程填写成人身份证正常进入游戏填写未成年人身份证进入防沉迷计时状态剩余时长不足 5 分钟收到提醒弹窗剩余时长归零强制下线弹窗出现凌晨 4:30 登录按前一天时长周期计算早上 5:01 登录当日时长重置修改系统时间被拦截或提示校准App 切后台 10 分钟再回来总时长相应增加 10 分钟断网后继续玩下次联网时补报时长这套清单看起来基础但每次都能救我一命。早年我做类似 SDK 集成时只验证了“能弹窗”就提交结果答辩时老师随口问“后台挂起这段时间算不算时长”我当场没答上来。从那以后每接一个端我都强制把上面这张表从头到尾走一遍尤其是时间回拨和后台补扣宁可多做五分钟验证也不要在关键时刻露怯。希望帮到你。本文还有配套的精品资源点击获取