kotlin 编写一个简单的天气预报app (七)使用material design

一、优化思路

对之前的天气预报的app进行了优化,原先的天气预报程序逻辑是这样的。

使用text和button组合了一个输入城市,并请求openweathermap对应数据,并显示的功能。

但是搜索城市的时候,可能会有错误,比如大小写,比如拼写之类的,所以打算给他升级一下。

目标:

  1. 在搜索的时候需要更够显示搜索的结果,然后在搜索的结果中显示符合的城市列表
  2. 需要有全球的城市数据,搜索结果过滤后,显示符合的城市
  3. 在符合搜索结果的内容中,可以点击需要的城市,并把城市的天气显示在主界面里。

二、准备城市的资料

获取城市的json压缩包

网址:https://bulk.openweathermap.org/sample/

下载链接:https://bulk.openweathermap.org/sample/city.list.json.gz

把下载文件加入进项目里,可以有两个位置一个是res/assert,一个是res/raw,它们的主要区别在于:

  1. res/raw
    • res/raw 目录用于存储原始文件,这些文件不会被 Android 资源编译器进行处理或修改。
    • 您可以在这个目录下放置各种类型的原始文件,例如音频文件、视频文件、文本文件等。
    • 资源文件放置在 res/raw 目录下会保持原始状态,不会被修改。
  2. res/assert
    • res/assert 目录也用于存储原始文件,但其中的文件会被 Android 资源编译器进行压缩和优化处理。
    • 通常用于存放一些较小的非常规资源文件,例如 JSON 文件、XML 文件等。
    • 资源文件放置在 res/assert 目录下会被压缩和优化,这可能会使得访问这些资源稍微快一些。
      如果希望保持资源文件的原始状态,不经过任何修改或处理,可以将它们放置在 res/raw 目录下。
      如果资源文件较小且希望进行优化处理,可以考虑放置在 res/assert 目录下。

这里我们把它放在res/raw目录下,因为它是一个gz的压缩文件。

三、解压城市的gz文件

为这个文件编写代码,让软件在启动时检查是否有解压文件,如果没有解压文件就解压到files目录下。

files 文件夹通常用于存储应用的私有文件。这些文件是应用专用的,其他应用无法访问。以下是关于 files 文件夹的一些主要特点:

  1. 私有性:files 文件夹中的文件只能被创建它们的应用访问,其他应用无法直接访问这些文件。
  2. 持久性:与 cache 目录不同,files 文件夹中的文件不会因为系统资源不足而被清除。这些文件会持久保存,直到应用被卸载或明确删除。
  3. 文件访问:可以通过 Context 对象提供的方法,如 openFileOutput() 和 openFileInput(),来访问 files 文件夹中的文件。
  4. 存储位置:files 文件夹通常位于应用的私有数据目录中。具体路径通常为 /data/data/包名/files/
  5. 用途:files 文件夹适用于存储各种类型的应用数据,如用户配置、日志文件、缓存数据等。

创建一个CityListDataManager的类,并在init时判断文件是否存在,如果不存在就把gz文件解压到files目录下:

kotlin 复制代码
class CityListDataManager(private val context: Context) {
    private val tag = "CityListDataManager"
    private val jsonFileName = "city.list.json"
    private val cityListJsonFile : File = File(context.filesDir, jsonFileName)
    private lateinit var cityDataList : Array<CityData>
    init {
        CoroutineScope(Dispatchers.IO).launch {		//使用线程执行,避免阻塞主线程
            if(!isExistCityListJsonFile()) {		//判断文件是否存在
                unzipGzFile(context)				//如果不存在就解压
            }
        }
    }

    private fun isExistCityListJsonFile() : Boolean {
        val isExisted = cityListJsonFile.exists()		
        Log.d(tag, "city list json file is existed:$isExisted")
        return isExisted
    }

    private suspend fun unzipGzFile(context: Context) {
        withContext(Dispatchers.IO) {
            try {
                context.resources.openRawResource(			//使用openRawResource打开raw目录下的文件
                    com.example.myweather.R.raw.city_list_json).use { rawIn ->
                    GZIPInputStream(rawIn).use { gzipIn ->		//使用GZIP来读取gz文件
                        FileOutputStream(cityListJsonFile).use { fileOut ->		//使用FileOutputStream读取文件内容
                            gzipIn.copyTo(fileOut)		//把文件解压到files目录下
                        }
                    }
                }
            } catch (ex: Exception) {
                ex.printStackTrace()
            }
        }
    }
}

四、读取所以城市数据

当我们解压了gz文件后,解压出来的文件是json格式的。包含了所有的城市数据。

这时候我需要选择解析json格式的工具,我搜索了相关的内容发现有两种方式,一种是JsonObject另一种是Gson。

我觉得Gson更简单,就选了这个

引入Gson库:

kotlin 复制代码
dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    implementation(libs.androidx.activity)
    implementation(libs.androidx.constraintlayout)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    //network
    implementation(libs.com.squareup.retrofit2)
    implementation(libs.com.squareup.retrofit2.converterGson)
    implementation(libs.org.greenrobot.eventbus)
    implementation(libs.androidx.recyclerview)
    implementation(libs.com.google.code.gson)
}

对应的libs.versions.toml内容:

bash 复制代码
constraintlayout = "2.1.4"
retrofit = "2.9.0"
converter-gson = "2.9.0"
eventBus = "3.2.0"
recyclerview = "1.3.2"
gson = "2.10.1"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
com-squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
com-squareup-retrofit2-converterGson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "converter-gson" }
org-greenrobot-eventbus = { group = "org.greenrobot", name = "eventbus", version.ref = "eventBus" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
com-google-code-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

为Json内容创建对应的类,两个数据类:CoordCityDataCoord 类表示地理坐标,包含经度 (lon) 和纬度 (lat)。CityData 类表示城市数据,包含城市的ID (id)、名称 (name)、州/省 (state)、国家 (country) 以及该城市的地理坐标 (coord)。这也对应json文件的数据格式

kotlin 复制代码
data class Coord (
    val lon: Double,
    val lat: Double
)
data class CityData(
    val id: Int,
    val name: String,
    val state: String?=null,
    val country: String,
    val coord: Coord
)

读取json格式函数,用于逐行读取文件内容并将其解析为 CityData 对象列表。这里使用了 Gson 库来解析 JSON 数据。具体步骤如下:

  1. 创建 Gson 实例。
  2. 打开应用的文件目录,并指定要读取的文件名。
  3. 使用 FileInputStream 打开文件,并创建 BufferedReader 来逐行读取文件内容。
  4. 使用 StringBuilder 来构建完整的文件内容。
  5. 在 while 循环中,逐行读取文件内容,并将每行添加到 StringBuilder 中。
  6. 读取完成后,将 StringBuilder 中的内容转换为字符串。
  7. 使用 Gson 的 fromJson 方法将 JSON 字符串转换为 CityData 对象数组。
  8. 将解析后的 CityData 对象数组发送到事件总线(EventBus)中。
    如果发生 IO 异常,将打印异常堆栈跟踪信息。
kotlin 复制代码
private fun readContentOneByOne(context: Context) = try {
    val gson = Gson()
    val file = File(context.filesDir, jsonFileName)
    val fis = FileInputStream(file)
    val reader = BufferedReader(InputStreamReader(fis))
    val stringBuilder = StringBuilder()
    var line : String?
    while (reader.readLine().also { line = it } != null) {
        stringBuilder.append(line).append("\n")
    }
    val fileContent = stringBuilder.toString()
    cityDataList = gson.fromJson(fileContent, Array<CityData>::class.java)

    Log.d(tag, "get city data list done and send event bus")
    EventBus.getDefault().post(CityDataListReadyEvent(cityDataList.toList()))

} catch (e: IOException) {
    e.printStackTrace()
}

CityListDataManagerinit中调用它

bash 复制代码
    init {
        CoroutineScope(Dispatchers.IO).launch {
            if(!isExistCityListJsonFile()) {
                unzipGzFile(context)
            }
            readContentOneByOne(context)
        }
    }

根据最新的Material Design的Search说明文档,可以在MainActivity里套用它的模版来使用:

xml 复制代码
<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

  <!-- NestedScrollingChild goes here (NestedScrollView, RecyclerView, etc.). -->
  <androidx.core.widget.NestedScrollView
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      app:layout_behavior="@string/searchbar_scrolling_view_behavior">
    <!-- Screen content goes here. 这里放显示的主内容 -->
  </androidx.core.widget.NestedScrollView>

  <com.google.android.material.appbar.AppBarLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content">
    <com.google.android.material.search.SearchBar
        android:id="@+id/search_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/searchbar_hint" />
  </com.google.android.material.appbar.AppBarLayout>

  <com.google.android.material.search.SearchView
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:hint="@string/searchbar_hint"
      app:layout_anchor="@id/search_bar">
    <!-- Search suggestions/results go here (ScrollView, RecyclerView, etc.). 这里是放搜索结果 -->
  </com.google.android.material.search.SearchView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

遇到的问题:

我之前的项目并不是用Android Material Design设计的,只是一个简单的TextView和Button组合的搜索结果。

当我想要使用Material Design相关的控件时,发现引用相关的库后调用SerachBar和需要配置说明文档里不存在的一些属性,否则就会崩溃。

当我为SearchBar设置了属性后,SearchView加入后,仍然还是会崩溃。我搜索了各种问题都无法解决,最后我新建了一个项目,把代码重新拷贝进去后,问题消失了。

分析:可能是我之前建的工程版本较低导致的,我通过对比gradle文件发现,不会崩溃的版本sdk更高。具体我也说不上来为什么,就只能这么用了。

六、使用SearchBar和SearchView对接

当我插入上面的模版后,发现点击SearchBar并不会跳转显示SerachView。

不断尝试后发现可以这样做,让点击SearchBar后出现SearchView

kotlin 复制代码
		binding.searchBar.apply {
            setOnClickListener { binding.searchView.show() }
        }

而在SearchView中输入了内容,点击确认进行搜索的方式是这样的:

kotlin 复制代码
        binding.searchView.editText.setOnEditorActionListener { v, _, _ ->
            val filterText = v.editableText.toString()
            Toast.makeText(v.context, "the text: $filterText", Toast.LENGTH_SHORT).show()
            return@setOnEditorActionListener false
        }

这样我们一个基本的搜索框架就搭建好了,还需要把结果数据显示出来

七、用recycleview显示搜索结果

在SearchView中添加recycleview控件:

kotlin 复制代码
    <com.google.android.material.search.SearchView
        android:id="@+id/search_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:hint="@string/editTextCityHint">
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/cityDataRecyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
    </com.google.android.material.search.SearchView>

创建一个对应的CityDataAdapter类,这里我们需要添加一个itemClick事件,当有一项被点击时,可以触发一个消息:

kotlin 复制代码
package com.example.myweather.cityListUtils

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.myweather.R


class CityDataAdapter(private val originCityDataList: List<CityData>) :
    RecyclerView.Adapter<CityDataAdapter.ViewHolder>() {

    private var filterCityDataList : MutableList<CityData> = originCityDataList.toMutableList()
    var onItemClick: ((CityData) -> Unit)? = null

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        init {
            view.setOnClickListener {
                if(absoluteAdapterPosition != RecyclerView.NO_POSITION)
                    onItemClick?.invoke(filterCityDataList[absoluteAdapterPosition])
            }
        }
        val cityDataId : TextView = view.findViewById<TextView>(R.id.city_data_id)
        val cityDataName : TextView = view.findViewById<TextView>(R.id.city_data_name)
        val cityDataCountry : TextView = view.findViewById<TextView>(R.id.city_data_country)
        val cityDataCoordinate: TextView = view.findViewById<TextView>(R.id.city_data_coordinate)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(
            R.layout.city_data_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val cityData = filterCityDataList[position]
        holder.cityDataId.text = cityData.id.toString()
        holder.cityDataName.text = cityData.name
        holder.cityDataCountry.text = cityData.country
        holder.cityDataCoordinate.text = buildString {
            append(String.format("%.1f", cityData.coord.lon))
            append(",")
            append(String.format("%.1f", cityData.coord.lat))
        }
    }
    override fun getItemCount() = filterCityDataList.size

    @SuppressLint("NotifyDataSetChanged")
    fun setFilter(filterText: String) {
        if(filterText.isEmpty()) {
            filterCityDataList.clear()
            filterCityDataList.addAll(originCityDataList)
        } else {
            filterCityDataList.clear()
            for (item in originCityDataList) {
                if (item.name.lowercase().contains(filterText.lowercase())) {
                    filterCityDataList.add(item)
                }
            }
        }
        notifyDataSetChanged()
    }
}

在MainActivity里,添加处理解析完城市json格式的所有城市数据:

在主线程中接收城市数据列表准备就绪的事件,并调用 updateCityDataList 方法更新城市数据列表。

kotlin 复制代码
    @Subscribe(threadMode = ThreadMode.MAIN)
    fun onReceiveCityDataListReadyEvent(event: CityDataListReadyEvent) {
        Log.d(tag, "on received city data list ready event ${event.cityDataList.size}")
        updateCityDataList(event.cityDataList)
    }

当CityDataAdapter的某一项被点击时,调用OnItemClick事件来处理。需要在注册的时候就绑定相关的消息:

  1. 创建了一个 CityDataAdapter 的实例,并传入城市数据列表作为参数。
  2. 为 CityDataAdapter 设置了点击事件的回调函数 onItemClick,在点击城市数据项时执行以下操作:
    • 获取点击的城市名称 cityName。
    • 使用 RetrofitClient 获取该城市的天气信息和预报信息。
    • 创建一个包含点击项名称的提示消息 message。
    • 隐藏搜索视图(可能是搜索框之类的)。
    • 弹出一个短暂的 Toast 消息显示 message。
  3. 将适配器设置到城市数据的 RecyclerView 中,用于显示城市数据列表。
kotlin 复制代码
    private fun updateCityDataList(cityDataList: List<CityData>) {
        val adapter = CityDataAdapter(cityDataList)
        adapter.onItemClick = { cityData ->
            val cityName = cityData.name
            RetrofitClient.getWeatherByCityName(cityName)
            RetrofitClient.getForecastByCityName(cityName)
            val message = "Click item name: $cityName"
            binding.searchView.hide()
            Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
        }
        binding.cityDataRecyclerView.adapter = adapter
    }

最终的效果图:


最后遇到的奔溃问题

由于json文件解析需要一点时间,如果软件启动时,就去搜索,因为adapter是空的,所以软件会崩溃。

最后我在初始化的时候先创建了一个空的队列,避免崩溃:

kotlin 复制代码
    private fun initView() {
        supportFragmentManager.beginTransaction()
            .replace(R.id.fragment_container, CityWeatherFragment())
            .commit()

        binding.forecastRecyclerView.layoutManager = LinearLayoutManager(this)
        binding.cityDataRecyclerView.layoutManager = LinearLayoutManager(this)
        binding.cityDataRecyclerView.adapter = CityDataAdapter(emptyList<CityData>())
        binding.searchBar.apply {
            setOnClickListener { binding.searchView.show() }
        }
        binding.searchView.editText.setOnEditorActionListener { v, _, _ ->
            val filterText = v.editableText.toString()
            Toast.makeText(v.context, "the text: $filterText", Toast.LENGTH_SHORT).show()
            val cityDataAdapter : CityDataAdapter= binding.cityDataRecyclerView.adapter as CityDataAdapter
            cityDataAdapter.setFilter(filterText)
            return@setOnEditorActionListener false
        }
    }
  1. 使用 supportFragmentManager 开始一个事务,并将一个 CityWeatherFragment 替换到 ID 为 fragment_container 的容器中。
  2. 设置 forecastRecyclerView 和 cityDataRecyclerView 的布局管理器为 LinearLayoutManager,以确保它们的布局是线性的。
  3. 为 cityDataRecyclerView 设置一个空的城市数据列表适配器 CityDataAdapter,以便后续更新城市数据。
  4. 为搜索栏 searchBar 设置点击事件监听器,点击时显示搜索视图 searchView。
  5. 为搜索视图的编辑文本框设置编辑动作监听器,当用户执行编辑动作时(比如按下回车键)执行以下操作:
    • 获取编辑框中的文本内容。
    • 弹出一个短暂的 Toast 消息,显示文本内容。
    • 从 cityDataRecyclerView 的适配器中获取 CityDataAdapter 实例,并调用其 setFilter 方法,传入文本内容作为过滤条件。
相关推荐
阿巴斯甜19 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker20 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952721 小时前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇2 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android