简介:这是一份面向Android初学者与进阶开发者的实战示例源码,围绕模拟登录学信网查询四六级成绩这一具体场景,串联网络请求、数据解析与用户认证等核心技能点。项目共50个文件,以class编译产物、xml布局与配置、png图片资源及jar依赖库为主,另含java源码、apk安装包与dex文件,压缩包约1.46MB,结构完整可直接导入研究。已有739人学习下载,说明其在同类练手项目中具备一定参考价值。代码中可看到OkHttp或HttpURLConnection发起请求、Jsoup解析HTML页面提取成绩的思路,配合EditText与按钮完成登录交互,并涉及HTTPS传输、密码加密存储、INTERNET权限声明及try-catch异常处理等细节。对于想理解Android网络编程全流程、补齐UI事件处理与安全隐私认知的开发者,这份源码提供了可运行、可拆解的实践样本,便于对照学习与二次改造。
1. 模拟登陆学信网查四六级:一份能跑通的 Android 源码到底解决了什么
每年四六级出分那几天,总有人问我能不能做个 App 自动查成绩。市面上的查分工具要么要手动填一堆信息,要么查完就闪退。这份 Android 模拟登陆学信网查询四六级的源码,核心就干一件事:用代码把「打开网页、填账号密码、过验证、拿成绩」这条链路在手机端跑通。它适合两类人——想学 Android 网络请求与 Cookie 管理的开发者,以及需要把查分能力嵌进自己工具里的从业者。源码基于 Java 与 OkHttp 实现,覆盖了登录态维持、验证码处理、成绩页解析三个关键环节。下面我按「先搞懂原理、再动手复现、最后避开坑」的顺序拆开讲,每一步都落到能抄的代码和参数上。
2. 模拟登陆的底层链路:从 Cookie 到会话维持
2.1 为什么不能只发一个 POST 就完事
很多人第一次做模拟登陆,直觉是「找到登录接口,把账号密码 POST 过去,拿到响应就完事」。真跑起来会发现,第二次请求成绩页直接返回未登录。原因在于学信网这类站点用的是标准的 Session + Cookie 机制:登录请求成功后,服务端在响应头里下发Set-Cookie,后续所有请求必须带上这个 Cookie,服务端才认你是同一个人。OkHttp 默认不会自动帮你管理 Cookie,你得自己挂一个CookieJar。
常见做法是给 OkHttpClient 配一个内存级的 CookieJar,把每次响应的 Cookie 存下来,下次请求时按域名取出来塞进请求头。这里有个细节:Cookie 有path和domain属性,简单粗暴地全量存取在单域名场景下能用,但一旦涉及跳转多个子域就会翻车。我一般会按 host 做一层过滤,只存目标域名的 Cookie。
// 内存 CookieJar:按 host 存取,避免跨域污染 public class MemoryCookieJar implements CookieJar { private final Map<String, List<Cookie>> store = new HashMap<>(); @Override public void saveFromResponse(HttpUrl url, List<Cookie> cookies) { // 只保留目标域名的 Cookie,防止跳转时串味 if (url.host().contains("chsi.com.cn")) { store.put(url.host(), cookies); } } @Override public List<Cookie> loadForRequest(HttpUrl url) { List<Cookie> cookies = store.get(url.host()); return cookies != null ? cookies : new ArrayList<>(); } }这段代码的关键在saveFromResponse里的 host 判断。学信网登录过程会经过account.chsi.com.cn和my.chsi.com.cn等不同子域,如果不做过滤,A 子域的 Cookie 被带到 B 子域,轻则登录态丢失,重则触发风控。loadForRequest返回空列表而不是 null,是因为 OkHttp 内部对返回值做了非空假设,返回 null 会在某些版本上抛 NPE,这个坑我踩过。
2.2 登录请求的参数构成与请求头伪装
光有 Cookie 还不够。服务端会校验User-Agent、Referer、Content-Type这几个头,缺一个都可能被判定为非浏览器请求。尤其是Referer,登录接口通常要求它指向登录页本身,否则直接 403。参数方面,除了账号密码,往往还有隐藏字段(比如lt、execution这类由登录页动态生成的令牌),这些值必须先从登录页 HTML 里抓出来再回填。
// 第一步:GET 登录页,抓隐藏字段 Request pageReq = new Request.Builder() .url("https://account.chsi.com.cn/passport/login") .header("User-Agent", UA) // 伪装成移动端浏览器 .header("Accept", "text/html") .build(); Response pageResp = client.newCall(pageReq).execute(); String html = pageResp.body().string(); // 用正则抓 lt / execution 等隐藏字段(实际项目建议用 Jsoup) String lt = extractByRegex(html, "name=\"lt\" value=\"([^\"]+)\""); String execution = extractByRegex(html, "name=\"execution\" value=\"([^\"]+)\""); // 第二步:POST 登录表单 RequestBody form = new FormBody.Builder() .add("username", account) .add("password", pwd) .add("lt", lt) .add("execution", execution) .add("_eventId", "submit") .build(); Request loginReq = new Request.Builder() .url("https://account.chsi.com.cn/passport/login") .header("User-Agent", UA) .header("Referer", "https://account.chsi.com.cn/passport/login") .header("Content-Type", "application/x-www-form-urlencoded") .post(form) .build(); Response loginResp = client.newCall(loginReq).execute();这里UA建议用移动端 Chrome 的字符串,因为学信网对移动端和 PC 端返回的页面结构不同,源码里统一按移动端处理,解析规则才对得上。extractByRegex只是示意,真实项目里 HTML 结构一变正则就废,用 Jsoup 的select更稳。_eventId=submit是表单提交的固定标记,漏了它服务端不认。登录响应如果是 302 跳转,OkHttp 默认会自动跟随,但你要在followRedirects(true)的前提下确认最终落地的 URL 是不是登录成功页,而不是又弹回登录页——后者说明账号密码或令牌有问题。
2.3 验证码:绕不开的那道坎
学信网登录在异常环境下会弹图形验证码,这是模拟登陆最容易翻车的地方。源码里的处理策略分两层:先判断登录页 HTML 里有没有验证码图片的src,有的话把图片下载下来交给人工输入,没有就直接提交。这不是什么高深方案,但胜在稳定——自动识别验证码在字符扭曲、干扰线多的场景下准确率很难保证,硬上 OCR 反而增加不确定性。
// 检测验证码并下载,交给 UI 层弹框输入 if (html.contains("captcha")) { String captchaUrl = extractByRegex(html, "id=\"captchaImg\" src=\"([^\"]+)\""); Request capReq = new Request.Builder() .url("https://account.chsi.com.cn" + captchaUrl) .header("Cookie", cookieHeaderFromJar()) // 验证码与当前会话绑定 .build(); byte[] imgBytes = client.newCall(capReq).execute().body().bytes(); // 交给 Activity 显示,用户输入后回填到表单的 captcha 字段 }注意验证码请求必须带上当前会话的 Cookie,否则服务端生成的验证码和你提交时校验的不是同一张,必然失败。这个细节在源码里体现为cookieHeaderFromJar(),它从 CookieJar 里拼出name=value; name2=value2格式的字符串。人工输入虽然不够「自动」,但在个人工具场景下完全够用,比折腾打码平台省心。
3. 成绩页解析:从 HTML 到结构化数据
3.1 登录成功后的跳转与成绩入口定位
登录成功后,服务端一般会 302 到个人中心页。你要做的不是直接请求成绩查询页,而是先访问个人中心,确认登录态有效(页面里包含用户名或「退出」字样),再去请求四六级成绩页。直接跳成绩页在某些会话状态下会被拦截,因为服务端要求先有个人中心的访问记录。源码里的顺序是:登录 POST → 跟随跳转 → 校验个人中心 → 请求成绩页。
// 校验登录态:个人中心页包含「退出登录」即视为成功 Request homeReq = new Request.Builder() .url("https://my.chsi.com.cn/archive/index.action") .header("User-Agent", UA) .build(); String homeHtml = client.newCall(homeReq).execute().body().string(); if (!homeHtml.contains("退出")) { throw new IllegalStateException("登录态无效,检查账号或验证码"); } // 请求四六级成绩页 Request scoreReq = new Request.Builder() .url("https://my.chsi.com.cn/archive/cet/query.action") .header("User-Agent", UA) .header("Referer", "https://my.chsi.com.cn/archive/index.action") .build(); String scoreHtml = client.newCall(scoreReq).execute().body().string();Referer在这里再次出现,成绩页对来源页有校验,直接从空白页请求会被拒。校验登录态用「退出」这个关键词是个土办法但有效,比解析复杂的 DOM 结构省事。如果站点改版把「退出」换成图标,这个判断就失效,所以更稳的做法是检查 HTTP 状态码加页面标题,源码里留了注释提示这一点。
3.2 用 Jsoup 抽取成绩字段
成绩页是服务端渲染的 HTML,表格结构相对固定。用 Jsoup 按table的行列定位,比正则可靠得多。源码里抽的是考试时间、总分、听力、阅读、写作翻译四个字段,对应表格里的固定列。
Document doc = Jsoup.parse(scoreHtml); Element table = doc.selectFirst("table.cet-table"); // 按 class 定位成绩表 if (table == null) { // 表不存在通常意味着没有成绩记录或页面结构变了 return Collections.emptyList(); } List<ScoreItem> items = new ArrayList<>(); for (Element row : table.select("tr").subList(1, table.select("tr").size())) { Elements cols = row.select("td"); if (cols.size() < 5) continue; // 跳过表头或异常行 ScoreItem item = new ScoreItem(); item.examTime = cols.get(0).text().trim(); item.total = parseIntSafe(cols.get(1).text()); item.listening= parseIntSafe(cols.get(2).text()); item.reading = parseIntSafe(cols.get(3).text()); item.writing = parseIntSafe(cols.get(4).text()); items.add(item); }subList(1, ...)跳过表头行,cols.size() < 5是防御性判断——页面里可能混入合并单元格或空行,不跳过会数组越界。parseIntSafe处理「-」或空字符串的情况,直接Integer.parseInt遇到非数字会抛异常,成绩页里缺考或未出分的字段经常是横杠。这套解析逻辑的边界在于:一旦站点把表格换成 div 布局,selectFirst("table.cet-table")就返回 null,此时应该降级到按文本特征匹配,而不是直接崩溃。
3.3 数据落库与 UI 展示的衔接
解析出来的ScoreItem列表,源码里用 Room 存了一份本地缓存,目的是出分高峰期反复请求容易被限流,缓存能让用户离线看历史成绩。Room 的实体定义和 DAO 不复杂,关键是examTime做主键,避免重复插入。
@Entity(tableName = "score_cache") public class ScoreEntity { @PrimaryKey @NonNull public String examTime; // 考试时间作为唯一键 public int total; public int listening; public int reading; public int writing; } @Dao public interface ScoreDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void upsert(List<ScoreEntity> list); @Query("SELECT * FROM score_cache ORDER BY examTime DESC") List<ScoreEntity> queryAll(); }OnConflictStrategy.REPLACE保证同一场考试重复查询时覆盖旧数据而不是报错。UI 层用LiveData观察 DAO 查询结果,网络请求成功后写库,界面自动刷新,不需要手动回调。这套「网络优先、缓存兜底」的模式在查分类工具里很常见,出分当天服务器压力大,缓存能显著减少无效请求。
4. 避坑排查:模拟登陆最容易翻车的五个地方
4.1 现象:登录返回 200 但后续请求全是未登录
原因通常是 Cookie 没存上,或者存了但没带对。OkHttp 的CookieJar如果返回的列表里 Cookie 的domain和请求 URL 的 host 不匹配,OkHttp 会静默丢弃。解决方法是打印loadForRequest的返回值,确认 Cookie 的 domain 字段和实际请求 host 一致;不一致就在saveFromResponse里用cookie.newBuilder().domain(url.host()).build()重写 domain。
4.2 现象:验证码明明输对了还是提示错误
原因是验证码请求和登录请求用的不是同一个会话。验证码图片的 GET 请求如果没带 Cookie,服务端会新开一个会话生成验证码,而你登录时用的是旧会话,校验自然对不上。解决方法是确保验证码请求走同一个OkHttpClient实例(共享 CookieJar),并且在请求头里显式带上当前 Cookie。
4.3 现象:成绩页解析出来是空的
先看scoreHtml里有没有「登录」字样——有的话说明登录态在请求成绩页时已经失效,多半是中间某次请求触发了会话刷新。再看表格的 class 名是否变了,站点改版是常态。解决办法是把解析逻辑里的选择器抽成常量,改版时只改一处;同时加一层「页面标题包含成绩查询」的前置校验,标题不对就直接报错而不是返回空列表。
4.4 现象:真机上请求超时,模拟器正常
模拟器走的是宿主机网络,真机走移动网络,差异通常在 DNS 和 TLS 握手。学信网的部分子域在移动网络下解析较慢,默认 10 秒超时不够。解决办法是把OkHttpClient的connectTimeout和readTimeout调到 20 秒,并开启retryOnConnectionFailure(true)。另外 Android 9 以上默认禁止明文 HTTP,虽然学信网是 HTTPS,但如果代码里混了 HTTP 的静态资源请求,需要在 manifest 里配usesCleartextTraffic或改用 HTTPS。
4.5 现象:频繁查询后账号被临时限制
这是最容易被忽视的一条。出分高峰期短时间多次请求,服务端会按 IP 或账号做频率限制,表现为返回验证码页或直接拒绝。解决办法是加请求间隔,两次查询之间至少隔 30 秒,并且把结果缓存到本地,用户重复打开 App 时先读缓存再决定是否发起网络请求。源码里在请求入口处加了一个基于System.currentTimeMillis()的时间戳判断,简单但管用。
5. 进阶:把模拟登陆封装成可复用的查询模块
5.1 用拦截器统一处理请求头与重试
前面每个请求都手动加User-Agent和Referer,代码重复且容易漏。更工程化的做法是写一个Interceptor,在chain.proceed之前统一补头,并对 302 跳转做一次登录态校验。
public class HeaderInterceptor implements Interceptor { private final String ua; public HeaderInterceptor(String ua) { this.ua = ua; } @Override public Response intercept(Chain chain) throws IOException { Request origin = chain.request(); Request req = origin.newBuilder() .header("User-Agent", ua) // 只有非登录页请求才补 Referer,避免登录页自身被覆盖 .header("Referer", origin.url().toString().contains("login") ? origin.url().toString() : "https://my.chsi.com.cn/archive/index.action") .build(); Response resp = chain.proceed(req); // 302 到登录页说明会话失效,抛出自定义异常供上层处理 if (resp.code() == 302 && resp.header("Location", "").contains("login")) { resp.close(); throw new SessionExpiredException("会话失效,需重新登录"); } return resp; } }拦截器里对Referer做了条件判断,登录页请求的 Referer 指向自身,其他请求指向个人中心,这样既满足服务端校验又不会把登录页的 Referer 写死。SessionExpiredException是自定义异常,上层捕获后触发重新登录流程,比在每个请求后判断状态码干净得多。这个拦截器挂到OkHttpClient.Builder().addInterceptor(...)上即可,注意用addInterceptor而不是addNetworkInterceptor,前者能拿到完整的请求链信息。
5.2 验证方法:三步确认模块可用
封装完不能只看「没报错」,要按三步验证。第一步,用错误密码登录,确认抛出的是「账号密码错误」而不是「会话失效」,说明错误分类正确。第二步,登录成功后连续请求两次成绩页,第二次不重新登录,确认 Cookie 维持有效。第三步,手动清空 CookieJar 再请求成绩页,确认抛出SessionExpiredException而不是返回空数据。这三步覆盖了认证、会话、失效三个核心路径,跑通基本就能放心用。
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 错误密码 | 用错误密码走登录流程 | 抛出账号密码错误异常 |
| 会话维持 | 登录后连续请求两次成绩页 | 两次均返回成绩数据 |
| 会话失效 | 清空 CookieJar 后请求成绩页 | 抛出 SessionExpiredException |
5.3 一个具体技巧:把登录态持久化到本地
每次打开 App 都重新登录体验很差。可以把 CookieJar 里的 Cookie 序列化到 SharedPreferences,下次启动时反序列化回填。注意 Cookie 有有效期,过期的不要恢复,否则会带着失效 Cookie 请求,反而触发风控。我一般会在恢复时过滤掉expiresAt早于当前时间的 Cookie,只保留有效的。这个技巧让二次打开 App 的查询耗时从十几秒降到两秒以内,体验差别很明显。
从那以后我每次做模拟登陆类需求,都会先把 Cookie 的存取和失效判断单独写成一个可测试的模块,再往上叠业务逻辑——血泪经验是,登录态的问题一旦混在业务代码里,排查起来就是黑匣子。希望这份拆解帮到你,源码里的注释和边界处理值得对着自己的场景再走一遍。
本文还有配套的精品资源,点击获取