- 网站主页:android.bit101.cn
- 主项目链接:BIT101
- 服务端链接:BIT101-GO
API文档:API
安装运行BIT101-Android需要Android 8.0及以上,动态适配系统主题功能需要Android 12及以上。
使用 JDK 21 和 Android SDK 36,执行 ./gradlew :app:assembleDebug 构建调试包。
发布包仅包含 arm64-v8a,与官方 1.4.1 的架构范围一致;Debug 不限制架构,支持模拟器。发布包使用原版签名,以便覆盖安装并保留用户数据。将 signing.properties.example 复制为仓库根目录的 signing.properties,填写密钥库路径、别名及密码后,执行 ./gradlew :app:assembleRelease。输出为 app/build/outputs/apk/release/app-release.apk。缺少签名配置时 Release 构建会失败,不会退回调试签名。
signing.properties 和密钥库文件已加入 Git 忽略规则,密码仅保存在本地。私下分发 APK 不需要修改发布站点;正式发布时再更新 BIT101-Android-Release 仓库的下载文件和版本信息。
- 自动同步并显示课程表
- 自动从乐学拉取并显示日程
- 校园地图
- 包含
BIT101网站全部功能 - 个性化显示效果配置
- 手动添加日程
- 手动修改课程表
- 桌面小组建显示课程和日程
- 地图显示定位、导航功能
欢迎提出你的想法✨
本项目还用于大二下学期的Android课程结课作业,以下摘自提交的文档。
注:由于篇幅限制,服务端仅介绍和客户端直接相关的一些部分,对于服务端更详细的说明请见GitHub。
项目整体使用MVVM的架构,有三层数据模型:底层Model层负责处理、提供数据,中间的ViewModel层负责对接数据并管理运行状态,顶层的View层使用模型层提供的数据和状态与用户进行交互。
具体地,底层的Model层主要使用了如下数据来源:
DataStore:负责存储一些简单的变量、设置等Room:提供SQLite数据库访问,存储了课程、日程等OkHttp:提供与学校接口的网络交互,还使用了Cookie Store保存网络访问过程中的CookieRetrofit:提供与BIT101服务端的网络交互Jetpack Security:系统级安全的存储方式,用于保存密码
中间的ViewModel层将数据层和视觉层隔离开,全部使用了响应式设计,尽可能使用Flow、State等进行数据传输,同时所有获取数据的操作都放到了协程里进行,以避免性能问题。
另外还用到了几个开源库用于处理数据:
Gson:用于处理JSON格式的字符串iCal4j:用于解析从乐学获取到的iCalendar日历标准格式的字符串
顶层的View层为单Activigy架构,使用Jetpack Compose+Material Design 3实现,通过绑定ViewModel管理数据和状态。另外还用到了一些其他的开源组件:
Navigation Compose:用于页面切换和管理导航MapCompose:用于显示地图Accompanist Webview:用于显示BIT101网页端Coil:用于显示图片
接下来将分模块介绍一些功能的实现。
学校网站的接口基本都是通过Cookie和各种各样的重定向来实现的,所有的其他页面都需要login.bit.edu的第三方认证,这个过程如果完全通过手动模拟完成将会非常痛苦,很难实现。在没有RESTful API的情况下,使用Retrofit这样高层次的框架反而不方便了,最好的方式就是完全模拟浏览器的行为。所以我的实现方式是使用OkHttp,并且通过加入一个cookie-store中间件实现Cookie的全自动管理,这样只需要像使用浏览器一样访问接口就可以了,权限验证将自动通过Cookie和重定向完成。
学校密码与短信认证由BIT-Login v4.0.2负责。安卓适配层将认证目标设为课表应用 wdkbby,检查认证回调与课表落地页,每次校园登录重新判断网络。教学中心预检先失败时,通过学校统一身份认证的已知 WebVPN 服务单独核验一次;密码错误或验证码未完成不放行。身份验证成功后,教学中心会话失效、权限拒绝或连接异常作为部分成功返回,允许进入应用并提示原因及稍后重试。即使没有可用 Cookie,也保留已验证的身份及凭据,业务查询按需恢复会话。SDK 客户端始终关闭。BIT101 登录独立运行,其失败不影响校园身份。学校认证失败或取消不会覆盖原账号的数据。
课表、考试、学期、校区楼宇及空教室占用由安卓教学中心适配层直接请求,共享业务 Cookie 与应用预热状态。空教室保留原 kxjas 接口、节次映射及分页参数;乐学日历继续使用 BIT-Login SDK。校园接口均从持久 CookieStore 恢复学校会话。BIT101 社区 API 使用 Retrofit/OkHttp 和 fake-cookie 请求头。校园身份验证成功但学校明确拒绝课表访问时保留校园登录,课表查询单独报告权限错误。
当前登录界面已接入短信验证码弹窗,图形验证码尚未接入。教学中心会话按当前 Cookie 与网络路线复用,首次查询统一加载课表首页、应用配置和语言资源,再初始化目标应用。WebVPN 指纹初始化允许未登录时的正常 302 跳转;SSO 回调遇到学校页面来源校验错误时,独立打开课表首页一次,仍失败则提示教务暂不可用,不重复认证。切号、重新认证或失败后重新初始化。课表、学期、校历、考试、校区楼宇及空教室接口在解析前检查响应,HTTP 401、未明确说明权限拒绝的 403、登录页或会话过期提示触发一次校园认证恢复;DNS、连接失败或超时允许切换一次路线。两类恢复共用一次业务重试预算,并发失效共用一次认证。明确的权限拒绝、服务端故障和普通解析错误直接返回,保留缓存。乐学接口维持原有恢复规则。需要验证码或凭据错误时标记校园账号需要重新验证,保留已有凭据和缓存。网络故障、限流和服务端错误也保留会话。退出会清除两套凭据;切换校园账号成功后替换为新账号。退出或切号同时清除课程、考试及学期缓存,保留自定义日程。
课程学分按小数解析和保存。数据库升级时重建旧课程缓存并清理旧考试缓存,需重新获取课表和考试安排。课表响应中的业务错误和不完整安排不会覆盖缓存。DDL 页面和设置页共用事务同步,保留已完成状态;读取、同步和编辑失败显示提示,编辑成功仅在写入完成后确认。 DDL 修改按顺序保存,读取重试会重新订阅设置和日程。乐学订阅与导入日程按校园账号隔离,下载结果提交时重新核对账号。数据库 v4 为乐学日程记录所属账号;升级时清理无法确认归属的旧版导入缓存,需重新获取一次乐学日程,自定义 DDL 保留。
课表首页和设置页共用 CourseSync,串行完成网络获取与账号校验后再提交。课程或校历失败保留原数据,不联网回滚旧学期;考试失败允许课程更新,保留已有考试并显示警告。课程与考试分别最多恢复一次校园会话,一次完整刷新至多两次恢复;考试重试不重拉课程,不调用 BIT101 后端。数据库删除与插入使用事务,学期和首日设置一起更新;设置写入失败时数据库替换回滚。提交与退出/切号互斥,取消或旧账号结果不能写回。首页失败持续显示步骤、原因及重试入口,设置页也展示具体失败原因。课表应用配置和语言资源仅检查状态及登录页,业务接口再按 JSON 解析。
空教室页面统一处理教学楼加载、教室查询及校园会话恢复的失败,显示手动重试入口。界面状态在主线程更新,同一教学楼请求合并,失败后停止自动刷新;请求取消时不提示加载失败,会话与账号校验通过后才发布查询结果及缓存。占用计算先判断当前节次,再计算后续空闲时间;短于用户阈值的课间合并为连续占用。查询学期及周次取自学校当前校历,不使用课表页选中的历史学期,同一日期与会话内查询不同教学楼复用校历结果。
退出先清除凭据,再清理课程、考试及学期缓存。缓存清理异常不阻止退出;无账号时屏蔽残留课程与考试,新账号必须在缓存清理成功后才能写入身份。自定义日程不受影响。
当前构建工具链为 Kotlin 2.4.10、Compose Compiler Kotlin 插件 2.4.10、KSP 2.3.10、AGP 8.13.2、Gradle 8.14.5、Hilt 2.57.2 和 Room 2.8.4。Java 与 Kotlin 的编译目标统一为 JVM 17,可使用 Android Studio 的 JBR 21 构建。BIT-Login 的 Android 依赖要求compileSdk 36,应用仍保持targetSdk 34。
由于需要实现自动重新登陆的功能,因此必须要保存学号和密码,但是直接保存这样的敏感信息显然不合适,因此我使用了Jetpack中的Security实现了密码的安全管理,这个库会使用的硬件加密的KeyStore生成和存储密钥,再用密钥配合EncryptedSharedPreferences存储数据,这样就可以保证用户帐号密码的安全。
然而美中不足的是,cookie-store是使用未加密的SharedPreferences存储Cookie的,这也会带来安全隐患,但由于时间限制,暂时还没有重写这部分的存储接口。
BIT101 与校园登录分开保存和使用,登录页提供以下路径:
- 校园账号:手机通过 SDK 登录学校,立即提交校园 Cookie 和凭据。随后尝试连接 BIT101:已有可用 token 则直接复用,否则调用原来的统一身份认证链路。社区失败时提示“校园登录成功,BIT101 暂未连接”,不回滚校园状态。社区请求尚未结束时,也会显示校园已成功并提供“进入校园”入口;进入会取消本次未完成的社区请求,保留校园会话。
- BIT101 → 统一身份认证:用户主动使用学校密码,通过 BIT101 服务端验证学校身份并换取社区 token;不重新执行本机校园登录,也不修改保存的学校密码。
- BIT101 → 密码登录:调用
/user/login,成功后加密保存已验证的社区密码摘要,供日后普通续期使用。
统一认证沿用 /user/webvpn_verify_init → /user/webvpn_verify → /user/register(loginMode=true)。这条路径会让 BIT101 服务端访问学校 WebVPN。新用户自动注册,初始密码使用本次学校密码摘要;已有用户只登录,不修改社区密码。该响应无法区分新用户与旧用户,也无法证明学校密码与社区密码相同,因此客户端不会将其保存为“已验证社区密码”。原有邮箱验证与注册/找回密码协议保持不变;本轮未新增安卓原生找回密码界面。
自动恢复按 token 状态选择路径:
- token 可用时直接复用,不调用
/user/check或登录接口。 - 已知 JWT 距到期不足一分钟(包括已到期),且本机保存了学校密码时,自动通过统一身份认证续期,无需学校密码与 BIT101 密码相同。业务 401 若遇到同样的到期状态,也采用此路径。自动统一认证失败按 1、2、4、8 天指数退避,此后保持 8 天;失败次数与下次尝试时间均持久化,取消也保留预留的退避间隔。普通密码失败与统一认证失败分别计数。任一社区登录成功后重置失败次数,统一认证成功仍保留至少 24 小时的自动续期间隔,避免异常短命 token 导致循环。若服务端
Retry-After更长则继续延后。续期任务单独持久化,401 清除旧 token 或进程重启后仍保留。 - 没有 token、token 格式无法读取到期时间、尚未到期却返回 401,或没有保存学校密码时,只尝试
/user/login。优先使用已验证的社区密码摘要,缺失时尝试保存的学校密码摘要;只有普通登录成功才保存为已验证摘要。普通失败不会自动转去服务端 WebVPN。
普通自动登录失败间隔改为 30 分钟、2 小时、此后每 24 小时一次,持续失败的前 24 小时最多约三次,之后每天约一次。已到期 token 的统一认证续期按上述指数退避等待,等待期间不再轮询普通密码登录。定时任务只在前台且有网络时运行,每 30 秒只检查本地条件;进入后台不补发请求,重启也不跳过冷却。业务 401 恢复由业务请求触发,遵守同一冷却规则。手动重试与手动登录可绕过自动冷却。
当前后端将密码错误和部分服务故障都返回 500,因此按暂时失败处理;普通登录明确返回 400/401/403 时暂停自动尝试。自动统一认证明确需要验证码或返回可识别的凭据错误时暂停,等待用户操作。统一认证失败不据此判定社区密码错误。
正常统一认证续期频率取决于 token 的实际有效期;后端 config_example.yml 示例为 2073600 秒(24 天),未核实线上配置。首次校园登录后若前台普通登录先成功,统一认证会复用其 token;若其失败,用户发起的统一认证仍可继续。
社区业务 401 后最多重试一次。并发普通续期共用一次请求;所有认证接口使用独立 HTTP 调度器,避免业务请求等待续期时占满调度器。退出或切号后,旧认证请求的结果不会覆盖新账号。统一认证最多顺序调用三个 BIT101 接口,中途失败即停止;这不等于学校侧仅三个 HTTP 请求,后端还会执行学校登录和重定向。
边界:校园登录成功不保证所有学校业务接口可用;社区统一认证成功不保证普通密码登录成功;只登录 BIT101 不会解锁校园功能。服务端统一认证要求图形验证码时,当前安卓会停止并提示使用网页或 BIT101 密码,不空验证码循环重试。取消/超时不能撤回服务端已经完成的注册,但不会把迟到的 token 写回已退出或切换的账号。
BIT101 网页的 fake-cookie 保存在 localStorage 中。WebView 仅向 BIT101 的同源页面同步登录状态,并单独记录上次同步的原生会话。原生会话没有变化时,不覆盖网页独立登录、注册或找回密码获得的 token,也不重新注入网页已拒绝的旧 token。同账号续期提供刷新提示,在用户点击或下一次整页加载时应用,避免打断表单;原生退出或切号会立即更新并重新加载网页,使页面内存中的旧账号一并失效。
这部分是使用Accompanist Pager库实现的,这个库现在已经并入了Jetpack Compose。
顶部左右滑动切换的Indicator指示块是基于官方文档的例子进行修改而来的,添加了一些缩放效果使得移动时看起来像果冻一样,实现了非常好的动画效果,这样的动画完全自己写的话还是比较困难。
因为Pager内部是使用LazyRow实现的,所以混动切换到边界时会有阴影效果,不太美观。经过百般搜寻,才找到了去掉的办法——将组件使用一个CompositionLocalProvider(LocalOverscrollConfiguration provides null){}包裹,想要设置阴影效果的话定义一个OverscrollConfiguration并传入即可。
地图模块的数据来源于OpenStreetMap,这是一个开源的地图项目,北京理工大学网络开拓者协会的成员曾在上面绘制了详细的校园地图,还建立了一个在线地图网站一点儿北理地图(A BIT of Map),非常感谢他们的贡献。
然而,OpenStreetMap在国内存在DNS污染的问题,客户端上往往无法正常访问,于是我先是在服务器上使用基于Docker的openstreetmap-tile-server搭建了地图瓦片服务,并部署了北京地区的地图,但是部署后发现占用服务器资源过大,很容易受到攻击,所以后来还是放弃了这个方案,使用服务器代理转发的方式来解决问题。
最终的解决方案是,先在服务器上修改hosts防止DNS污染,然后在Nginx上建立一个代理规则。当访问https://map.bit101.flwfdd.xyz/tile/{z}/{x}/{y}.png时,请求将被转发到https://tile.openstreetmap.org/{z}/{x}/{y}.png,虽然访问速度比较慢,但由于地图数据基本不会改变,所以可以在Nginx上加入缓存,学校地区的数据基本上就是用服务器本地的了。
另外注意,调用OpenStreetMap接口时需要传入User-Agent,否则会被拦截。我在调试时发现网页一切正常,但安卓上总是无法正常加载,找了好半天BUG、、
找到了一个开源项目MapCompose完美地契合了我的需求。
不过显示上有一个问题,地图组件显示出的文字过小,但是由于地图的绘制方式是位图而不是矢量,并没有办法直接修改文字大小,于是想到把地图组件强制放大。最后通过在地图组件上添加了一个Modifier.fillMaxSize(0.5f).scale(2f)即可放大两倍(相当于先在一半的大小上绘制地图,再拉到全屏大小),放大到其他倍数也同理。
另外,为了加快加载速度,并且让地图能够离线查看,在OkHttp上添加了一个cacheControl实现缓存。
自动登录BIT101功能的实现已经在之前介绍过了。
当识别到路由切换到成绩查询页面时,会执行一段JavaScript代码,以实现自动填入学号密码的功能。
而当识别到路由切换到非BIT101站点时,就会拦截在WebView中的访问,并跳转到系统浏览器访问,以避免复杂情况的处理和安全问题。
每次在应用内切换导航又回到WebView所在页面时,就会发现WebView的状态已经改变了。这是由于只使用了rememberWebViewState,查了半天翻到了GitHub上的一个issue,这个问题在一两个星期前通过一个新的rememberSaveableWebViewState解决了,将库更新到最新的alpha版本并更改为使用rememberSaveableWebViewState即可保存简单状态。
然而,现在的实现并没有完全恢复状态,只是单纯回到之前的页面和浏览位置(比如输入框的内容会丢失),但也先凑合用着吧。
WebView默认是不支持文件上传的,必须通过手动重写WebChromeClient的onShowFileChooser函数来实现。我们首先在Application中定义一个ActivityResultRegistry,然后在MainActivity中将此设置为ActivityResultContracts.GetMultipleContents(),最后通过一个全局的MutableStateFlow传递文件选择的结果。
这部分逻辑比较绕,主要参考了这篇文章。
在某些界面中我们不希望底部导航栏显示,在Material3中,底部导航栏是在顶层组合函数的Scaffold中定义的,所以配置也只能在顶层中完成。实现方法也并不复杂,只需要建立一个以路由为线索的State,然后再将导航栏使用AnimatedVisibility包裹即可。Material3中的动画实现还是很容易的,但是这里又遇到了一个问题,在导航栏切换的过程中已经切换到另外一个界面了,切换前后Padding会改变,这就导致了界面布局会有一个奇怪的抖动,而这个Padding实际上是由NavHost负责传下去,由下面的节点自行处理的,所以那些不需要导航栏的界面只要完全不理会传入的Padding就可以了。
BIT101的主视觉色分别为色相为24的橙色(如#FF9A57)和色相为192的蓝色(如#00ABD6)。
首先通过官方的Material Theme Builder网站简单生成了一套配色文件(Color.kt、Theme.kt),然后自行调整了Color.kt的配色方案。Android12+提供了根据系统设置动态生成配色的API,所以希望能够让用户自行决定是否打开这项功能,另外还希望用户能够决定是否启用深色模式,这些配置在设置界面被设置后将被写入DataStore,然后在Theme.kt文件中编辑BIT101Theme中进行监听,一旦状态改变就变更主题。由于BIT101Theme是整个应用的根结点,所以当它引起重构时,整个应用的主题也就改变了。
为了达到沉浸式的体验,还想要更改顶部的系统状态栏和底部的系统导航栏颜色与应用内颜色相适配。这只需要在Activity中更改window.statusBarColor和window.navigationBarColor即可实现。另外,当顶部状态栏为浅色或深色时,还需要同步更改顶部状态栏文字的颜色。这些颜色必须跟着主题的改变而改变,于是就将这部分代码放在BIT101Theme{}函数内,这样每次主题改变,在重构过程中就会被调用,而在其他情况下则不会被调用造成性能问题。
在很多情况下波纹的形状都会自动与组件形状相适配,但是一些情况(比如使用Modifier.clickable{}定义点击响应)下,波纹的形状会变成一个矩形,这时可以使用Modifier.clip()将组件的形状传递给波纹。
在使用rememberSaveable保存自定义对象时,一旦切换页面就会闪退,这是由于触发保存操作时,默认的序列化过程无法处理自定义对象。解决方法是要么手动定义对象的保存和恢复操作,或者直接使用remember代替rememberSaveable。
乐学的日程是.ics格式的,我使用了iCal4j库来解析,然而这个库并没有kotlin版本,于是就只能直接引入Java版本的,然而在编译时却会出现一些依赖库出现冲突,我尝试了很多方法都无法解决,报错信息也并不明确,幸运的是我最终在一个GitHub仓库中找到了解决方法,只需要在build.gradle中添加一些exclude语句排除一些有冲突的依赖库即可。
在本项目中使用了大量的第三方库,它们使用使用了各种不同的开源协议,出于对开源精神的尊重,应该在应用内添加一个显示所使用的开源库的功能。
然而,如果手动确认使用的开源库及其开源协议,这项工作将会非常繁琐,所以我使用了一个开源的gradle插件gradle-license-plugin来对项目中使用的开源库进行自动扫描,并生成一个txt文件放在assets目录下,然后在应用中读取这个文件并显示出来即可。
使用时,只需要运行gradle licenseDebugReport任务即可生成一个存放在assets下的txt文件,然后在应用中读取并显示即可。
打包时如果在build.gradle中设置minifyEnabled true,则打包时会对代码进行压缩混淆,原来15MB左右的安装包能压缩到惊人的4MB+,然而,安装后却发现部分功能无法正常使用,原因是一些依赖包运用到反射等特性,是不支持混淆压缩的。如果仍想要进行混淆压缩,需要手动配置排除一些依赖包,由于时间有限,且不压缩的APK本身也就不到20MB,也就先这样了,之后可以再优化。
Jetpack Compoe更新迭代很快,各种包又很多,而且互相之间还有版本要求依赖关系。经常因为一些功能需要更改包版本,更改后又编译不通过了。
后面发现了Compose BoM,真乃神器!只需要一行代码就完成了所有Compose包的版本管理。
结合官方文档、一些博客和自己的实践,总结了一些能优化Compose效率的原则:
- 状态不改变的代码不要在
Compose函数中执行,全部放到ViewModel、remember{}或LaunchedEffect里,避免重构时反复运行。 - 状态提升原则:
(我超 突然发现交上去的文档这里没写完呜呜呜)
- 选题贴合实际,有很大的实用价值
- 使用
Kotlin+Jetpack Compose编写,紧跟时代 UI使用Material Design 3组件构建,应用Material You设计思路- 数据和状态大量运用
Flow等响应式类型,传输路径清晰 - 所有
IO密集型操作均在协程中完成,执行效率高 - 充分考虑安全问题,保障用户隐私
- 使用
GitHub进行开源和代码管理 - 撰写了详细的文档,其他同学也可以贡献开发,项目具有可持续性
- 通过小组件在桌面线显示课程日程
- 使用生物识别提升安全性能
- 使用
NFC功能实现刷校园卡登录(可行性未验证) - 使用加密方式管理
Cookie - 其他
BIT101平台功能升级
