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

Android 公交查询应用开发实战指南

Android 公交查询应用开发实战指南 ★ FEATURED ARTICLE
摘要:本文以 Android Studio + Kotlin 为开发平台,手把手带你从零构建一款完全离线的本地公交查询应用。内容覆盖项目环境搭建与 MVVM 分层架构、公交数据文件的生成与哈希校验、城市列表的懒加载与搜索、基于图论的最短路径查询与双向 BFS 优化、结果页卡片与自定义 View 绘制、SQLite 表结构与三级缓存、离线降级与弱网处理、常见崩溃定位、性能与内存泄漏防治,最后给出可直接运行的完整实战代码,帮助新手把思路真正落地成能跑通的项目。关键词:Android Studio;Kotlin;MVVM;Hilt;SQLite;离线公交查询;最短路径算法;性能优化在开发一款本地公交查询应用时,很多开发者容易陷入一个误区:过分依赖在线地图 API,却忽视了离线数据的核心价值。实际场景中,用户往往在信号微弱的地下通道、拥挤的早晚高峰地铁里急需知道下一班车何时到达,或者在没有网络的老城区寻找换乘方案。如果应用此时无法响应,体验将大打折扣。构建一个完全基于本地数据、响应迅速且稳定的公交查询系统,不仅是对数据处理能力的考验,更是对架构设计、内存管理和异常处理机制的综合实战。这个项目并非简单的“增删改查”,它涉及从原始数据清洗、高效存储结构设计,到复杂路径算法的本地化实现。我们需要解决的核心痛点是如何在有限的移动端资源下,承载庞大的城市公交线网数据,并保证毫秒级的查询速度。对于正在学习 Android 开发、数据结构或希望提升工程化能力的开发者来说,这样一个涵盖全链路流程的项目极具参考价值。它不仅能让你掌握 SQLite 的高级用法和自定义视图绘制,更能深入理解如何在资源受限环境下进行性能调优。接下来,我们将一步步拆解这个系统的构建过程。从最初的环境搭建与架构选型开始,深入到数据生成的校验机制,再到界面交互的细节打磨。我们会重点探讨如何设计高效的查询算法,如何处理网络异常以保障离线可用性,以及如何定位那些隐蔽的崩溃问题。整个过程不依赖任何外部付费服务,完全依靠本地算力与精心设计的逻辑,旨在打造一个真正实用、健壮且可扩展的公交查询解决方案。① 项目环境搭建与基础架构设计启动项目前,明确技术栈与目录结构是至关重要的第一步。考虑到公交数据具有高度的结构化特征且查询频率极高,我们选择采用 Android Studio 作为开发平台,使用 Kotlin 语言进行原生开发,配合轻量级的关系型数据库 SQLite 作为核心存储引擎。这种组合既能保证数据读取的低延迟,又能有效控制安装包体积。在架构设计上,严格遵循 MVVM(Model-View-ViewModel)模式,将数据逻辑、业务处理与界面展示彻底解耦。Model层负责封装数据库操作与数据实体;ViewModel层持有业务状态,处理线路查询、站点匹配等核心逻辑,并对外暴露可观察的数据流;View层则专注于 UI 渲染与用户交互反馈。为了便于后续扩展,我们在项目根目录下建立了清晰的模块划分:data存放数据源与仓库,domain定义核心用例与算法,ui管理各类界面组件。此外,引入依赖注入框架 Hilt 来管理单例对象,确保数据库实例、线程池等资源在整个应用生命周期内高效复用,避免重复创建带来的开销。为了更直观地理解这套分层,下面用一张流程图展示一次查询请求在 MVVM 各层之间的流转路径,以及 Hilt 注入的数据库实例与线程池分别落在哪一层:用户操作:选择城市、发起查询调用用例,请求线路数据读操作:并发查询首次启动:读取预置数据文件注入注入注入返回实体对象返回领域模型更新可观察状态View 层Activity / Fragment / RecyclerViewViewModel 层持有 UI 状态,暴露 StateFlowRepository 层data 模块,统一数据入口SQLite 数据库Lines / Stations / Line_Stations本地数据文件assets 中的压缩 SQLite 包Hilt 单例:数据库实例@Singleton DatabaseModuleHilt 单例:线程池@Singleton Dispatchers / Executor各层职责边界可以这样理解:View层只负责渲染与事件分发,不碰任何数据源,也不做业务判断;ViewModel层是状态的唯一持有者,负责把 Repository 返回的领域模型转换成 UI 可直接消费的状态,并通过 Hilt 注入的线程池把耗时逻辑切到后台线程;Repository层是数据的唯一出口,向上屏蔽 SQLite 查询与本地数据文件读取的差异,向下统一管理数据库实例的生命周期。这样划分之后,任何一层内部实现替换都不会波及其他层,测试时也能用假实现轻松替换 Repository。新手落地第一步:在 Android Studio 里把项目骨架搭起来光看架构图容易“懂了但不会做”,这里把第 ① 节落到具体操作上,跟着点一遍就能得到一个可编译的空骨架。1)新建项目:打开 Android Studio,选择New Project → Empty Views Activity,语言选Kotlin,Minimum SDK选API 24,包名填com.example.busquery,点击Finish等待 Gradle 同步完成。2)建目录:在app/src/main/java/com/example/busquery/下右键New → Package,依次建出data(再建entity、db、repository三个子包)、domain、di、ui四个包。目录结构对齐后,后面第 ⑪ 节的代码就能直接按包名粘贴,不用再改package声明。3)开启 ViewBinding:在app/build.gradle.kts的android {}块里加上下面两行,避免后续到处写findViewById:android{// ... 其他配置保持不变buildFeatures{viewBinding=true}}4)验证环境:点一下工具栏的绿色Run,模拟器能正常显示 “Hello World” 就说明 JDK、SDK、Gradle 三件套都没问题。这一步别跳过——很多新手后面报的错,其实是环境没配好,而不是代码写错了。5)常见坑速查:Gradle 同步卡在Downloading:检查是否开了代理,或在gradle.properties里配置国内镜像源;提示Unsupported class file major version:说明 JDK 版本与 Gradle 不匹配,在Settings → Build, Execution, Deployment → Build Tools → Gradle里把 Gradle JDK 统一为 17;中文注释乱码:在Settings → Editor → File Encodings中把三处编码全部设为UTF-8。骨架搭好后,下一节我们就开始准备真正要喂给这个骨架的数据——公交数据文件的生成与校验。② 公交数据文件生成与校验机制公交数据的准确性是应用的生命线。原始数据通常来源于公开的交通数据集,格式多为 CSV 或 JSON,包含线路编号、站点名称、经纬度坐标、发车时间等信息。直接将这些 raw 数据放入应用会导致解析缓慢且占用大量内存,因此需要一个预处理阶段。我们编写了一个独立的数据转换脚本,在 Android Studio 构建阶段将原始数据清洗并转换为二进制或高度压缩的 SQLite 格式。在这个过程中,必须建立严格的校验机制。首先,对经纬度数据进行范围检查,剔除明显偏离城市地理范围的噪点;其次,验证线路的连通性,确保每一站都有明确的前驱和后继节点,防止出现“断头路”导致路径计算死循环。# 示例:简单的数据完整性校验逻辑defvalidate_route_stations(stations):iflen(stations)2:returnFalse,"线路站点数量不足"foriinrange(len(stations)-1):ifstations[i]['next_station_id']!=stations[i+1]['id']:returnFalse,f"站点{stations[i]['name']}后继连接错误"# 检查坐标合法性forstationinstations:ifnot(-90=station['lat']=90)ornot(-180=station['lon']=180):returnFalse,f"站点{station['name']}坐标非法"returnTrue,"校验通过"生成最终的数据文件后,计算其 MD5 或 SHA-256 哈希值,并在应用首次启动时进行比对。如果哈希值不匹配,说明数据文件可能在传输或存储过程中损坏,系统将自动触发修复流程或提示用户重新初始化,从而杜绝因数据错误引发的逻辑崩溃。新手落地第二步:在 Android Studio 里把数据文件真正做出来上一节讲的是“为什么要校验”,这一节我们把它变成能跑的操作。整个流程分三步:准备原始数据 → 用脚本清洗并生成bus.db→ 放进项目并校验哈希。1)准备原始数据:在项目根目录新建一个tools/文件夹(它不参与 APK 打包,只是本地工具目录),把从官方公开渠道下载的 CSV 放进去,例如tools/raw/stations.csv和tools/raw/line_stations.csv。字段建议至少包含:station_id, station_name, lat, lon与line_id, station_id, seq, travel_minutes。2)用 Python 脚本生成 SQLite 文件:在tools/下新建build_bus_db.py,用标准库sqlite3就能完成,不需要额外装依赖:# tools/build_bus_db.pyimportcsvimporthashlibimportsqlite3frompathlibimportPath RAW_DIR=Path("raw")OUT_DB=Path("bus.db")defcreate_schema(conn):conn.executescript(""" CREATE TABLE Lines ( lineId INTEGER PRIMARY KEY, lineName TEXT NOT NULL, cityCode TEXT NOT NULL, startStation TEXT, endStation TEXT ); CREATE TABLE Stations ( stationId INTEGER PRIMARY KEY, stationName TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL ); CREATE TABLE Line_Stations ( lineId INTEGER NOT NULL, stationId INTEGER NOT NULL, seq INTEGER NOT NULL, travelMinutes INTEGER NOT NULL, PRIMARY KEY (lineId, seq) ); CREATE INDEX idx_station_name ON Stations(stationName); CREATE INDEX idx_line_stations_station ON Line_Stations(stationId); """)defload_stations(conn):withopen(RAW_DIR/"stations.csv",newline="",encoding="utf-8")asf:rows=list(csv.DictReader(f))forrinrows:lat,lon=float(r["lat"]),float(r["lon"])ifnot(-90=lat=90)ornot(-180=lon=180):raiseValueError(f"站点{r['station_name']}坐标非法")conn.executemany("INSERT INTO Stations VALUES (?, ?, ?, ?)",[(int(r["station_id"]),r["station_name"],float(r["lat"]),float(r["lon"]))forrinrows])returnlen(rows)defload_line_stations(conn):withopen(RAW_DIR/"line_stations.csv",newline="",encoding="utf-8")asf:rows=list(csv.DictReader(f))# 校验 seq 连续性,断号会导致路径回溯失败by_line={}forrinrows:by_line.setdefault(int(r["line_id"]),[]).append(int(r["seq"]))forline_id,seqsinby_line.items():seqs.sort()ifseqs!=list(range(1,len(seqs)+1)):raiseValueError(f"线路{line_id}的 seq 不连续:{seqs}")conn.executemany("INSERT INTO Line_Stations VALUES (?, ?, ?, ?)",[(int(r["line_id"]),int(r["station_id"]),int(r["seq"]),int(r["travel_minutes"]))forrinrows])returnlen(rows)defmain():ifOUT_DB.exists():OUT_DB.unlink()conn=sqlite3.connect(OUT_DB)try:create_schema(conn)n_station=load_stations(conn)n_seg=load_line_stations(conn)conn.commit()conn.execute("VACUUM")finally:conn.close()digest=hashlib.sha256(OUT_DB.read_bytes()).hexdigest()print(f"生成完成:{n_station}个站点,{n_seg}条线路区间")print(f"SHA-256 ={digest}")print("把这个哈希值写进下面的 BusDbVerifier 常量里")if__name__=="__main__":main()在tools/目录下执行python build_bus_db.py,终端会打印站点数、区间数和最终的 SHA-256。把这个哈希值记下来,下一步要用。3)放进项目:把生成的bus.db复制到app/src/main/assets/bus.db。注意文件名必须和 Hilt 模块里createFromAsset("bus.db")的参数完全一致,大小写也不能错。4)首次启动时校验哈希:在data/包下新建BusDbVerifier.kt,用assets.open()流式计算 SHA-256,避免把整个文件读进内存:packagecom.example.busquery.dataimportandroid.content.Contextimportjava.security.MessageDigestimportjavax.inject.Injectimportjavax.inject.Singleton@SingletonclassBusDbVerifier@Injectconstructor(privatevalcontext:Context){// 换成脚本打印出来的真实哈希privatevalexpected="把 build_bus_db.py 输出的 SHA-256 填在这里"funverify():Boolean=runCatching{valmd=MessageDigest.getInstance("SHA-256")context.assets.open("bus.db").use{input-valbuffer=ByteArray(8*1024)while(true){vallen=input.read(buffer)if(len=0)breakmd.update(buffer,0,len)}}md.digest().joinToString(""){"%02x".format(it)}==expected}.getOrDefault(false)}在BusApp.onCreate()里调用一次即可,校验失败时给出明确提示,而不是让应用带着坏数据继续跑:@HiltAndroidAppclassBusApp:Application(){@Injectlateinitvarverifier:BusDbVerifieroverridefunonCreate(){super.onCreate()if(!verifier.verify()){// 生产环境可上报日志并引导用户重新安装android.util.Log.e("BusApp","bus.db 校验失败,数据文件可能已损坏")}}}5)新手常见坑速查:脚本报FileNotFoundError:确认是在tools/目录下执行,且raw/与脚本同级;应用启动抛SQLiteException: unable to open database:多半是assets/bus.db路径或文件名写错,或忘了在build.gradle.kts里保留assets目录;校验一直返回false:先确认expected常量没有多余空格,再确认复制到assets的文件与脚本生成的是同一份(重新生成后哈希会变);中文站点名乱码:CSV 保存为UTF-8编码,脚本里已显式指定encoding="utf-8"。数据文件准备好并校验通过后,下一节我们就用它来驱动城市列表界面。③ 城市列表界面布局与数据加载用户进入应用的第一眼通常是城市选择界面。为了提升体验,我们不能简单地罗列所有支持的城市,而应采用智能排序策略。默认优先展示用户当前定位所在的城市(需申请位置权限),其次是热门大城市,最后按拼音顺序排列其余城市。界面布局上,采用RecyclerView实现流畅的滚动效果。针对数据加载,实施分页或懒加载策略:仅当用户滚动到特定区域时才加载对应的城市条目,避免一次性加载数千条数据造成主线程阻塞。搜索功能是此页面的亮点,我们实现了前缀匹配算法,用户输入“北”字时,立即筛选出“北京”、“北海”等选项,并高亮显示匹配字符。数据源方面,城市列表本身数据量较小,可直接缓存在内存中。但在初始化时,仍需异步读取本地配置文件,防止界面卡顿。若检测到用户曾使用过某个城市,应在列表顶部增加“最近使用”专区,通过记录时间戳实现快速回访,这种细节往往能显著提升用户的好感度。新手落地第三步:在 Android Studio 里把城市列表跑起来前面讲的是“应该怎么做”,这一节我们把它变成能点、能滑、能搜的真实界面。整个流程分四步:写布局 → 建 Adapter → 在 ViewModel 里加载数据 → 在 Activity 里串起来。1)写布局:在app/src/main/res/layout/下新建activity_city.xml,顶部放一个搜索框,下面放RecyclerView:?xml version="1.0" encoding="utf-8"?LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"android:orientation="vertical"android:padding="12dp"EditTextandroid:id="@+id/etSearch"android:layout_width="match_parent"android:layout_height="wrap_content"android:hint="输入城市名,如:北"android:imeOptions="actionSearch"android:inputType="text"android:maxLines="1"/androidx.recyclerview.widget.RecyclerViewandroid:id="@+id/recyclerCity"android:layout_width="match_parent"android:layout_height="0dp"android:layout_marginTop="8dp"android:layout_weight="1"//LinearLayout再新建条目布局item_city.xml,一个城市一行,右侧留出“最近使用”标记位:?xml version="1.0" encoding="utf-8"?LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="wrap_content"android:gravity="center_vertical"android:orientation="horizontal"android:padding="14dp"TextViewandroid:id="@+id/tvCityName"android:layout_width="0dp"android:layout_height="wrap_content"android:layout_weight="1"android:textSize="16sp"/TextViewandroid:id="@+id/tvRecentTag"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="最近使用"android:textColor="#FF9800"android:textSize="12sp"android:visibility="gone"//LinearLayout2)建 Adapter:在ui/包下新建CityAdapter.kt。这里用ListAdapter+DiffUtil,列表刷新时只重绘变化的行,避免整屏闪烁:packagecom.example.busquery.uiimportandroid.view.LayoutInflaterimportandroid.view.ViewGroupimportandroidx.recyclerview.widget.DiffUtilimportandroidx.recyclerview.widget.ListAdapterimportandroidx.recyclerview.widget.RecyclerViewimportcom.example.busquery.databinding.ItemCityBindingdataclassCityItem(valcityCode:String,valcityName:String,valisRecent:Boolean=false)classCityAdapter(privatevalonClick:(CityItem)-Unit):ListAdapterCityItem,CityAdapter.VH(DIFF){companionobject{privatevalDIFF=object:DiffUtil.ItemCallbackCityItem(){overridefunareItemsTheSame(a:CityItem,b:CityItem)=a.cityCode==b.cityCodeoverridefunareContentsTheSame(a:CityItem,b:CityItem)=a==b}}innerclassVH(valbinding:ItemCityBinding):RecyclerView.ViewHolder(binding.root)overridefunonCreateViewHolder(parent:ViewGroup,viewType:Int):VH{valbinding=ItemCityBinding.inflate(LayoutInflater.from(parent.context),parent,false)returnVH(binding)}
阅读完成 · 觉得有帮助?
咨询建站