基于Kotlin与Android Studio的星座运势App开发实战

发布时间:2026/9/15 14:36:10
基于Kotlin与Android Studio的星座运势App开发实战 简介这是一份基于Android Studio开发的星座运势App完整源码项目面向Android初学者、移动应用课程设计学生及对星座类应用感兴趣的开发者重点解决从界面搭建到网络请求解析的完整实现问题。项目整合了引导欢迎页、星座运势查询、星座解析与星座配对等模块其中星座解析采用本地JSON数据解析运势与配对通过第三方API发送HTTP请求并解析返回JSON逻辑清晰、注释详细可帮助读者快速掌握Android网络请求与数据解析的实战流程。压缩包共797个文件约18.5MB以XML布局文件、JSON数据文件、Java源码、PNG图片资源以及构建生成的DEX/CLASS文件为主目录结构完整便于按模块阅读和二次开发。目前已有2966人学习下载适合直接运行体验效果也适合作为课程设计或毕业设计的参考蓝本。1. 从选星座到看运势Android Studio 里的星座App先要解决哪些问题刚开始用 Android Studio 做星座 App很多人第一反应是去申请一个算命 API结果卡在鉴权、密钥和收费上。其实星座 App 最核心的部分是数据规则12 个星座的日期范围、今日运势的更新时间、以及今日/本周/本月三个页签之间怎么保持状态。把这些用 JSON 和 Kotlin 数据类固定下来后面的 UI 才有地方落。我按平时带新人的顺序走一遍先建工程、定 JSON 字段再做卡片选择页和 ViewPager 详情页最后说清楚日期判断、分享和发布前要改的配置。整个过程不需要后端也不需要图片加载库Android Studio 一个工程就能跑通。新手照着做至少能得到一个 UI 能看、功能完整的星座 App。2. 在 Android Studio 里建项目、定数据模型先把星座数据存成 JSON我一般会先让 UI 依赖一份本地 JSON而不是马上接网络。这样做的好处是数据格式能先用假数据验证等 UI 和逻辑稳定后再把 JSON 换成接口返回值不会一上来就被网络环境、密钥和跨域问题绊倒。下面从创建工程的依赖开始到把 JSON 映射成 Kotlin 对象一次走完。2.1 创建工程与依赖用一张配置表固定版本新建项目时选择 Empty Views Activity语言选 KotlinMinimum SDK 可以先选 API 26。不要选 Empty Activity 带 Compose 的模板目的是让 XML 布局和传统 View 体系保持简单后面的 RecyclerView 和 ViewPager2 教程也更通用。app 模块的 build.gradle 里至少要有这些依赖dependencies { implementation androidx.core:core-ktx:1.12.0 // Kotlin 基础扩展 implementation com.google.android.material:material:1.11.0 // CardView / TabLayout implementation androidx.constraintlayout:constraintlayout:2.1.4 // 扁平化布局 implementation androidx.recyclerview:recyclerview:1.3.2 // 星座列表 implementation androidx.viewpager2:viewpager2:1.0.0 // 左右滑动的三个页签 implementation com.google.code.gson:gson:2.10.1 // JSON 解析 }这些版本号只反映我本地能跑通的组合你新建工程时按 Android Studio 的版本提示升级也没问题。需要注意viewpager2和recyclerview是不同的依赖ViewPager2 内部会依赖 RecyclerView但显式写上以后不会因为版本冲突而出现编译异常。依赖配置好之后在src/main目录下新建assets文件夹把星座数据放在assets/constellation.json。assets 目录不会像 res 那样自动生成资源 ID所以用文件路径读取更直观也不会触发打包时的 XML 编译问题。2.2 星座 JSON 的结构设计与字段说明JSON 是这套 App 的数据契约我建议直接把星座的基础信息和三种运势写在一个文件里。示例{ signs: [ { id: aries, name: 白羊座, startMonth: 3, startDay: 21, endMonth: 4, endDay: 19, symbol: ♈, color: #FF5252, luckyNumber: 9, luckyColor: 红色, today: 整体运势走高适合开始一件拖了很久的事。, week: 本周重心在合作周三前后容易遇到贵人。, month: 本月适合主动出击下旬注意财务规划。 } ] }字段含义见下表字段类型作用idString星座唯一标识用于跳转和请求参数nameString显示名例如“白羊座”startMonth / startDay / endMonth / endDayInt公历日期范围用于自动判断星座symbolString星座符号直接用 Unicode 字符“♈”省去图标资源colorString主题色以#RRGGBB存储UI 里可以动态设置luckyNumber / luckyColorInt/String幸运数字与幸运颜色today / week / monthString今日、本周、本月运势文案把幸运色同时存成中文和色值两份是为了让列表页能显示“幸运色红色”同时详情页需要的时候可以直接用 color 字段去染色不用再做映射。2.3 用 Kotlin 数据类和 AssetManager 加载本地 JSON有了 JSON 契约下一步就是把它变成内存里的对象。新建data/Constellation.ktdata class Constellation( val id: String, // 与 JSON 的 id 字段一致 val name: String, val startMonth: Int, val startDay: Int, val endMonth: Int, val endDay: Int, val symbol: String, // 星座符号 val color: String, val luckyNumber: Int, val luckyColor: String, val today: String, // 今日运势文案 val week: String, val month: String ) data class ConstellationWrapper(val signs: ListConstellation)ConstellationWrapper是为了匹配 JSON 最外层的signs字段。如果 JSON 里每个字段名和 Kotlin 属性名完全一致Gson 不需要额外注解一旦出现start_month这类下划线命名就需要在属性上追加SerializedName(start_month)。我建议直接把 JSON 的 key 也写成驼峰减少一层映射。读取 assets 里 JSON 的仓库类可以这样写class ConstellationRepository(context: Context) { private val gson Gson() private val jsonString: String context.assets .open(constellation.json) // 打开 assets 根目录文件 .bufferedReader() .use { it.readText() } // use 自动关闭流 fun loadAll(): ListConstellation { return gson.fromJson(jsonString, ConstellationWrapper::class.java).signs } fun findById(id: String): Constellation? { return loadAll().firstOrNull { it.id id } } }assets.open返回的是InputStreambufferedReader()会按 UTF-8 读取避免中文乱码use会在读取结束后自动关闭流。loadAll()每次都重新解析整个 JSON对小文件没问题如果以后数据量大了可以把loadAll()的结果缓存到内存字段里。2.3.1 放在哪个目录、为什么路径不能大写把constellation.json放到src/main/assets/constellation.json运行后context.assets.open(constellation.json)的路径就是这个文件名。assets 目录里的文件名不能以数字开头也不能有大写字母否则部分 AGP 版本会在打包时报错。如果你改成assets/data/signs.json对应代码就要写open(data/signs.json)目录层级要一致。3. 星座选择页用 RecyclerView 和 Material 组件做不卡的宫格 UI列表页的体验直接决定用户是否会继续点击。12 个星座用 3 列宫格展示每张卡片放符号、姓名和幸运颜色看起来清爽也不需要加载网络图片。这一章把布局、适配器和点击态写完重点会放在 Item 复用和 UI 界面卡顿的常见原因上。3.1 用 GridLayoutManager 排列 12 个星座卡片主页布局只需要一个 RecyclerView根布局用 FrameLayout 或 ConstraintLayout 都可以。activity_main.xml里这样声明!-- 列表不贴边滚动时有呼吸感 -- androidx.recyclerview.widget.RecyclerView android:idid/recyclerConstellations android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipToPaddingfalse android:padding12dp android:scrollbarsvertical /clipToPaddingfalse很关键。它让 RecyclerView 滚动时内容能滑到 padding 区域里这样卡片滑动到边缘时不会一下贴到屏幕边视觉上更透气。在 MainActivity 中配置 LayoutManager 和 Adapterclass MainActivity : AppCompatActivity() { private val repository by lazy { ConstellationRepository(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val recycler: RecyclerView findViewById(R.id.recyclerConstellations) recycler.layoutManager GridLayoutManager(this, 3) // 3 列宫格 val adapter ConstellationAdapter(repository.loadAll()) { sign - val intent Intent(this, DetailActivity::class.java) intent.putExtra(SIGN_ID, sign.id) // 只传 id避免序列化整个对象 startActivity(intent) } recycler.adapter adapter } }GridLayoutManager(this, 3)指水平方向 3 列回调里的Intent只传了SIGN_ID这是为了让 ViewModel 在详情页里重新从仓库取值避免整个对象序列化产生额外复杂度。3.2 用 CardView 和星座符号代替网络图片Item 布局我直接用MaterialCardView做容器里面放一个大符号和名字。优点是圆角、描边、阴影都内置不用自己写 shape 文件。com.google.android.material.card.MaterialCardView android:idid/cardRoot android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_margin6dp app:cardCornerRadius16dp app:cardElevation2dp app:strokeWidth1dp app:strokeColor#E0E0E0 LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding12dp !-- 星座符号直接用 Unicode不加载网络图片 -- TextView android:idid/textSymbol android:layout_widthmatch_parent android:layout_heightwrap_content android:gravitycenter android:textSize40sp android:text♈ / TextView android:idid/textName android:layout_widthmatch_parent android:layout_heightwrap_content android:gravitycenter android:textColor#333333 android:textSize15sp android:text白羊座 / /LinearLayout /com.google.android.material.card.MaterialCardView这里strokeWidth加 1dp 描边是为了在白色背景上让卡片边界清晰而不是依赖阴影。textSymbol直接用 Unicode 星座符号省掉图片加载。如果想让符号变色可以在代码里用setTextColor(Color.parseColor(sign.color))让每个星座有主题色UI 立刻生动起来。常用卡片属性可以按这个表调属性建议值作用cardCornerRadius16dp控制圆角大小过大内容会被裁切cardElevation2dp阴影高度过高在低版本上会模糊strokeWidth1dp描边宽度解决白卡看不清边界layout_margin6dp卡片间距注意复用 ItemDecoration 更灵活3.3 适配器与点击反馈避免 onBindViewHolder 拖慢 UIAdapter 的写法直接影响滑动帧率。常见新手错误是每次onBindViewHolder都执行findViewById或者把文件读取、网络请求放在里面。class ConstellationAdapter( private val items: ListConstellation, private val onClick: (Constellation) - Unit ) : RecyclerView.AdapterConstellationAdapter.VH() { class VH(view: View) : RecyclerView.ViewHolder(view) { val root: MaterialCardView view.findViewById(R.id.cardRoot) val symbol: TextView view.findViewById(R.id.textSymbol) val name: TextView view.findViewById(R.id.textName) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_constellation, parent, false) return VH(view) } override fun onBindViewHolder(holder: VH, position: Int) { val sign items[position] holder.symbol.text sign.symbol holder.name.text sign.name holder.symbol.setTextColor(Color.parseColor(sign.color)) // 把主题色设置成点击涟漪色 holder.root.rippleColor ColorStateList.valueOf( Color.parseColor(sign.color) ) holder.root.setOnClickListener { onClick(sign) } } override fun getItemCount(): Int items.size }VH在onCreateViewHolder里一次性缓存了 3 个 View 对象后续复用 Item 时只更新内容不需要再找控件。rippleColor可以用主题色做点击涟漪反馈这就是 Material 组件比普通 CardView 好用的地方。要说明的是Color.parseColor只接受#RRGGBB或#AARRGGBB如果 JSON 里色值写错格式这里会抛IllegalArgumentException。用runCatching包一层也行我更推荐在写 JSON 时就用脚本校验而不是运行到一半崩溃。4. 星座运势详情页用 ViewPager2 和 ViewModel 把今日/本周/本月串起来详情页是“功能丰富”最集中的地方三个页签对应三种运势左右滑动切换同时还得保证旋转屏幕后当前选中星座不丢。这里用 ViewPager2 承载页面ViewModel 保存数据让 ui 层与数据层分开。后面这个结构接网络接口时也不用推翻重写。4.1 用 FragmentStateAdapter 搭出三个页签在activity_detail.xml里放TabLayoutViewPager2就行com.google.android.material.appbar.MaterialToolbar android:idid/toolbar android:layout_widthmatch_parent android:layout_height?attr/actionBarSize app:title白羊座 / com.google.android.material.tabs.TabLayout android:idid/tabLayout android:layout_widthmatch_parent android:layout_heightwrap_content app:tabModefixed app:tabGravityfill / androidx.viewpager2.widget.ViewPager2 android:idid/viewPager android:layout_widthmatch_parent android:layout_height0dp app:layout_constraintTop_toBottomOfid/tabLayout app:layout_constraintBottom_toBottomOfid/tabLayout /这里ViewPager2的高度用0dp加 ConstraintLayout 约束方式而不用match_parent是为了让 TabLayout 固定高度页面内容自适应剩余空间。Tab 与 ViewPager 的连接需要在 Activity 里手动new TabLayoutMediator(...).attach()。页面适配器继承FragmentStateAdapter三个 Fragment 都使用同一个DetailFragment只是传入参数不同class DetailPagerAdapter(activity: AppCompatActivity) : FragmentStateAdapter(activity) { override fun getItemCount(): Int 3 // position 0今日 1本周 2本月 override fun createFragment(position: Int): Fragment { return DetailFragment.newInstance(position) } }页面与数据的映射关系positionTab 标题DetailFragment 的 TYPE 参数取数字段0今日0today1本周1week2本月2monthFragmentStateAdapter会自动保存每个 Fragment 的状态用户在三个页签间滑动时不会重建页面。相比直接放三个 Fragment 手动切换它能复用回收机制内存占用更低。4.2 用 ViewModel 管理当前星座数据页面要显示哪个星座不应该由 Fragment 自己从 Intent 里读而是放到 Activity 级别的 ViewModel 里。因为三个页签各自有 Fragment如果每个 Fragment 都去 Intent 取SIGN_ID不仅重复而且切换时无法感知同一个数据源。先写一个极简 ViewModelclass DetailViewModel : ViewModel() { private val _selected MutableLiveDataConstellation() val selected: LiveDataConstellation _selected fun setSelected(sign: Constellation) { _selected.value sign } }在DetailActivity中通过by viewModels()拿到同一个实例class DetailActivity : AppCompatActivity() { private val viewModel: DetailViewModel by viewModels() private lateinit var repository: ConstellationRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_detail) repository ConstellationRepository(this) val signId intent.getStringExtra(SIGN_ID) ?: aries // 把选中的星座放进 ViewModelFragment 通过 activityViewModels 读取 viewModel.setSelected(repository.findById(signId) ?: error(unknown sign)) val adapter DetailPagerAdapter(this) findViewByIdViewPager2(R.id.viewPager).adapter adapter TabLayoutMediator( findViewById(R.id.tabLayout), findViewById(R.id.viewPager) ) { tab, position - tab.text arrayOf(今日, 本周, 本月)[position] }.attach() } }by viewModels()是 Activity 作用域的 ViewModel旋转屏幕时不会销毁。如果把DetailViewModel改成by activityViewModels()在 Fragment 里使用就能直接观察selected拿到同一个星座对象。这里给 Fragment 传position只是告诉它显示哪段文案。DetailFragment 里的观察逻辑class DetailFragment : Fragment() { private val viewModel: DetailViewModel by activityViewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return inflater.inflate(R.layout.fragment_detail, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val type requireArguments().getInt(TYPE) val textView view.findViewByIdTextView(R.id.tvDetail) // 只在 View 可见时观察避免 Fragment 销毁后还收到更新 viewModel.selected.observe(viewLifecycleOwner) { sign - textView.text when (type) { 0 - sign.today 1 - sign.week 2 - sign.month else - } } } companion object { fun newInstance(type: Int) DetailFragment().apply { arguments Bundle().apply { putInt(TYPE, type) } } } }这里使用viewLifecycleOwner而不是lifecycleOwner是为了避免 Fragment 的 View 销毁后还持有旧的 Lifecycle 观察者导致数据更新但 UI 已分离。TYPE从 arguments 读取和 ViewPager2 的 position 一一对应。4.3 给本地数据加一层“像接口”的开关很多小白把本地 JSON 和网络请求写成一团导致后来说不清数据到底从哪来。我习惯定义一个接口本地和远程都实现它// UI 层依赖接口不关心数据来自本地还是网络 interface HoroscopeDataSource { fun getDailySign(signId: String): String // 今日 fun getWeeklySign(signId: String): String // 本周 fun getMonthlySign(signId: String): String // 本月 } class LocalHoroscopeDataSource : HoroscopeDataSource { override fun getDailySign(signId: String): String 本地今日数据 override fun getWeeklySign(signId: String): String 本地本周数据 override fun getMonthlySign(signId: String): String 本地本月数据 }如果后面要接远端接口只需要写一个RemoteHoroscopeDataSource在实现里用 Retrofit 或 OkHttp 发起请求然后把返回值填入同样签名的方法。UI 层依赖的是接口而不是具体实现替换时不用改 Fragment。对于“功能丰富”这一点我会在接口方法里加一个LocalDate参数因为今日运势到了第二天应该自动更新。没有日期参数的接口很容易让用户看到昨天的内容。5. 从能跑到能发布日期判断、分享卡片和 UI 层优化功能跑通后真正决定能否上线的是边界情况和体验细节。日期算错一个边界用户就会在 4 月 19 日看到白羊还是金牛的错误结果分享图生成失败功能丰富就成了一个摆设。这一章给出我常用的处理方式。5.1 用 LocalDate 判断星座日期边界不要用startMonth和endMonth直接比较字符串因为跨年的星座摩羯座会把简单问题复杂化。我直接在数据类上写一个扩展函数fun Constellation.contains(date: LocalDate): Boolean { return when { // 普通星座例如白羊座 3.21-4.19 startMonth endMonth - { date.monthValue startMonth date.monthValue endMonth !(date.monthValue startMonth date.dayOfMonth startDay) !(date.monthValue endMonth date.dayOfMonth endDay) } // 跨年星座例如摩羯座 12.22-1.19 startMonth endMonth - { date.monthValue startMonth || date.monthValue endMonth } // 同月内起始例如某些特殊星座按天判断 else - date.dayOfMonth startDay date.dayOfMonth endDay } }这个函数把跨年的情况拆开startMonth endMonth是普通星座startMonth endMonth是摩羯座这类跨年星座。注意普通情况里要同时判断月份和日不能用简单的区间包含。测试时重点看每个星座的起始日和结束日例如测试日期期望星座验证点2025-01-10摩羯座跨年结束日期是 1 月 19 日2025-04-19白羊座结束日当天仍属于白羊2025-04-20金牛座结束日次日切换到金牛5.2 把运势页生成图片并分享分享功能是“功能丰富”里比较有感知的一个。常见做法是把当前详情页的根 View 绘制成 Bitmap再用 FileProvider 分享。生成 Bitmap 的代码fun viewToBitmap(view: View): Bitmap { // 先把 View 画到 Canvas 上 val bitmap Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) val canvas Canvas(bitmap) view.draw(canvas) return bitmap }注意view.width如果是 0说明 view 还没有完成布局必须在view.post {}里调用。分享时把 Bitmap 写入cacheDir然后通过 FileProvider 拿 URIfun shareBitmap(context: Context, bitmap: Bitmap) { // 写入缓存目录避免申请存储权限 val file File(context.cacheDir, horoscope_${System.currentTimeMillis()}.png) file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } val uri FileProvider.getUriForFile(context, ${context.packageName}.fileprovider, file) val intent Intent(Intent.ACTION_SEND).apply { type image/png putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(intent, 分享运势)) }FileProvider需要在 Manifest 里配置androidx.core.content.FileProvider并指定file_paths.xml路径里把cacheDir暴露出来。这个配置不写7.0 以上设备分享时必崩。5.3 用 Android Studio 自带工具定位 UI 卡顿常见的星座 App 卡顿不在网络而在列表和详情页的布局层级太深。Android Studio 可以边运行边开Layout Inspector在真机上选择当前界面就能看到 View 树。如果一个 Item 的层级超过 4 层就该考虑用ConstraintLayout压平。真机上可以在“开发者选项”里打开Profile GPU Rendering选择“在屏幕上显示为柱状图”。柱状图每个柱形代表一帧超过绿色基准线的柱形越多说明 UI 线程耗时越明显。这时回头检查onBindViewHolder里是否做了文件读写、图片解码或复杂字符串操作。常见的三个卡顿来源和处理方式卡顿来源现象处理findViewById 重复调用滑动时掉帧在 ViewHolder 里缓存控件布局嵌套过深首帧慢、启动卡用 ConstraintLayout 压平层级onBind 里做文件 IO滑动到该 item 卡一下提前把数据加载到内存6. 收尾三个 Android Studio 里的小设置决定 App 是“玩具”还是“产品”功能写完只是第一步。我见过不少项目在模拟器上能跑一装到真机就闪退或者能安装但无法升级。下面三个配置是发布前必须过一遍的都和 Android Studio 直接相关。6.1 改掉默认的 minSdk 和 targetSdk在 app 的build.gradle里defaultConfig { applicationId com.example.constellation minSdk 26 // 覆盖 Android 8.0 以上设备 targetSdk 34 // 适配新系统的文件和通知要求 versionCode 1 versionName 1.0 }minSdk 26覆盖了 Android 8.0 以上设备也让LocalDate和ViewPager2这些 API 不用额外做兼容。targetSdk 34表示你已经按新版系统要求适配过文件权限和后置通知等行为否则在部分机型上会出现运行到FileProvider或通知时直接崩溃。6.2 混淆配置必须为 Gson 留口子如果 release 包打开minifyEnabledGson 靠反射创建 Kotlin 数据类会把Constellation的字段名混淆成单字母导致解析结果全是默认值。在模块的proguard-rules.pro里加# 保留数据包下的类Gson 反射需要字段名 -keep class com.example.constellation.data.** { *; }这条规则只保留数据包下的类不影响其他代码混淆。不要把整个项目都-keep掉否则 APK 体积会明显变大。6.3 用一行命令打正式签名包在 Android Studio 里打开Build Generate Signed Bundle / APK按向导创建一个 jks 签名文件你的密码和别名只写在本地别提交进 Git。命令行方式配置好签名后执行# 生成正式签名 APK ./gradlew assembleRelease生成的 APK 在app/build/outputs/apk/release/下安装前确认状态栏显示的是 release 而不是 debug。如果你想同时看大小和 UI 变化可以在build.gradle里加splits { abi { enable true } }但最简单的方法是先生成标准 APK再在Analyze APK里查看哪部分占空间最大。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询