开发 SunnyWeather:Android 天气预报 App(下)

记录选中的城市

在上篇博文中,我们已经成功完成了展示天气的功能。但我们并没有对选中的城市进行保存,所以每当退出应用重进时,还需要重新搜索并选择城市。为此,我们现在就来实现记录选中城市的功能。

Dao 层

因为要存储的数据是单个的地点信息,不属于复杂的关系型数据,所以我们使用 SharedPreferences 来存储选中的城市即可。

data/dao 包下创建 PlaceDao 单例类。

kotlin 复制代码
import androidx.core.content.edit

object PlaceDao {

    // 用于序列化和反序列化 Place 对象
    private val gson = Gson()

    // SharedPreferences 文件的名称
    private const val PREFERENCES_NAME = "sunny_weather"

    // 存储 Place 对象的键
    private const val KEY_PLACE = "place"


    /**
     * 将 Place 对象保存到 SharedPreferences
     */
    suspend fun savePlace(place: Place) = withContext(Dispatchers.IO) {
        sharedPreferences().edit {
            // 将 Place 对象转换为 JSON 字符串后存储
            putString(KEY_PLACE, gson.toJson(place))
        }
    }

    /**
     * 从 SharedPreferences 中读取已保存的 Place 对象
     */
    suspend fun getSavedPlace(): Place? = withContext(Dispatchers.IO) {
        val placeJson = sharedPreferences().getString(KEY_PLACE, null)
        placeJson?.let {
            try {
                // 将 JSON 字符串反序列化为 Place 对象
                gson.fromJson(it, Place::class.java)
            } catch (e: Exception) {
                e.printStackTrace()
                null
            }
        }
    }


    /**
     * 检查是否已有地点信息被保存
     */
    suspend fun isPlaceSaved() = withContext(Dispatchers.IO) {
        sharedPreferences().contains(KEY_PLACE)
    }

    /**
     * 获取 SharedPreferences 的实例
     */
    private fun sharedPreferences() =
        SunnyWeatherApplication.context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)

}

PlaceDao 中,我们封装了存储、读取数据的接口。由于 SharedPreferences 的读写是文件 IO 操作,可能会阻塞主线程(数据量大或系统繁忙时)。所以我们将这些方法声明为挂起函数,并通过使用 withContext(Dispatchers.IO) 函数,确保它们在 IO 线程池中执行。

仓库层

Repository 中封装上述接口,作为数据来源的统一入口。

kotlin 复制代码
object Repository {
    
    ...

    /**
     * 保存地点信息
     */
    suspend fun savePlace(place: Place) = PlaceDao.savePlace(place)

    /**
     * 获取已保存的地点信息
     */
    suspend fun getSavedPlace() = PlaceDao.getSavedPlace()

    /**
     * 检查是否已保存地点信息
     */
    suspend fun isPlaceSaved() = PlaceDao.isPlaceSaved()
}

ViewModel

这几个接口的业务逻辑和 PlaceViewModel 相关,所以我们需要在 PlaceViewModel 再做一层封装。

kotlin 复制代码
class PlaceViewModel : ViewModel() {
    
    ...
    
    private val _savedPlace = MutableLiveData<Place?>()
    val savedPlace: LiveData<Place?> = _savedPlace

    /**
     * 从仓库层获取已保存的地点信息
     */
    fun getSavedPlace() {
        viewModelScope.launch {
            val place = Repository.getSavedPlace()
            _savedPlace.value = place
        }
    }


    /**
     * 保存选中的地点信息
     */
    fun savePlace(place: Place) {
        viewModelScope.launch {
            Repository.savePlace(place)
            _savedPlace.value = place
        }
    }
}

实现功能

PlaceFragment 中增加列表项的点击逻辑,并在 Fragment 创建时检查是否已有记录。

kotlin 复制代码
class PlaceFragment : Fragment() {

    // ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // 实现自动跳转
        checkSavedPlace()

        // 设置 RecyclerView 和 Adapter
        binding.recyclerView.layoutManager = LinearLayoutManager(activity)
        adapter = PlaceAdapter { place ->
            // 存储选中的城市
            viewModel.savePlace(place)
            // 跳转到天气详情页
            navigateToWeather(place)
        }
        binding.recyclerView.adapter = adapter
        
        // ...
    }
    
    /**
     * 检查是否有已保存的城市记录
     */
    private fun checkSavedPlace() {
        // 触发 ViewModel 加载已保存的地点数据
        viewModel.getSavedPlace()
        viewModel.savedPlace.observe(viewLifecycleOwner) { place ->
            // 如果有城市被保存过
            if (place != null) {
                // 直接跳转到天气详情页
                navigateToWeather(place)
            }
        }
    }

   /**
     * 跳转到 WeatherActivity
     */
    private fun navigateToWeather(place: Place) {
        val intent = Intent(requireActivity(), WeatherActivity::class.java).apply {
            putExtra("key_place", place)
        }
        startActivity(intent)
        // 结束当前 Activity,防止用户返回
        requireActivity().finish()
    }
    
    // ...
}

这里我们进行了几处修改:

  1. 在点击列表项跳转到 WeatherActivity 之前,调用了 PlaceViewModelsavePlace() 方法存储了选中的城市。

  2. 封装了跳转到 WeatherActivity 的逻辑到 navigateToWeather 方法中。

  3. 然后在 onViewCreated 方法中,我们调用了 checkSavedPlace() 方法检查是否已有存储的城市数据,如果有,就会直接跳转到 WeatherActivity,而无需用户每次都搜索并选择城市。

现在,当已选择某个城市后,下次再次进入应用时,会直接跳转到天气页,并展示该城市的天气信息。

手动刷新天气和切换城市

现在,你会发现有个很严重的bug。那就是当你选择一个城市后,就无法再回到搜索页搜索并查看其他城市的天气了。

所以,现在我们来完成切换城市以及手动刷新城市的功能。

手动刷新天气

我们通过下拉刷新来完成天气信息的刷新。

修改 activity_weather.xml 布局文件,在最外面套上一层 SwipeRefreshLayout,这样就可以进行下拉刷新了。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipeRefreshLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.core.widget.NestedScrollView
        android:id="@+id/weatherLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:overScrollMode="never"
        android:scrollbars="none"
        android:visibility="visible">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">

            <include
                android:id="@+id/nowLayout"
                layout="@layout/now" />

            <include
                android:id="@+id/forecastLayout"
                layout="@layout/forecast" />

            <include
                android:id="@+id/lifeIndexLayout"
                layout="@layout/life_index" />

        </LinearLayout>
    </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

WeatherActivity 中添加下拉刷新的逻辑。

kotlin 复制代码
class WeatherActivity : AppCompatActivity() {

    // ...

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        // 观察 weatherLiveData 的数据变化
        viewModel.weatherLiveData.observe(this) { result ->
            val weather = result.getOrNull()
            if (weather != null) {
                // 如果成功获取到天气,则显示天气信息
                showWeatherInfo(weather)
            } else {
                Toast.makeText(this, "无法成功获取天气信息", Toast.LENGTH_SHORT).show()
                result.exceptionOrNull()?.printStackTrace()
            }
            // 结束下拉刷新动画
            binding.swipeRefreshLayout.isRefreshing = false
        }

        // 触发首次数据加载
        currentPlace?.let {
            refreshWeather()
        } ?: run {
            Toast.makeText(this, "获取地点信息失败", Toast.LENGTH_SHORT).show()
            finish()
        }

        // 设置下拉刷新监听器
        binding.swipeRefreshLayout.setOnRefreshListener {
            refreshWeather()
        }
    }

    /**
     * 刷新天气信息
     */
    private fun refreshWeather() {
        // 显示下拉刷新进度条
        binding.swipeRefreshLayout.isRefreshing = true
        currentPlace?.let {
            // 发起刷新
            viewModel.refreshWeather(it.copy())
        }
    }
    
    // ...
}

此次修改中,我们将首次数据的逻辑放到了 refreshWeather() 方法中,并添加了显示下拉进度条的逻辑。通过 setOnRefreshListener()SwipeRefreshLayout 设置了监听器,每当用户下拉时,就会调用 refreshWeather() 方法。最后,我们在 weatherLiveData 的观察者中,无论请求成功或失败,都要隐藏刷新进度条。

现在,手动刷新天气的功能就完成了。

切换城市

切换城市功能需要搜索全球城市的数据,对此,我们可以复用之前的 PlaceFragment。只需在天气页的布局中引入即可。

为了不遮挡天气页,我们将其放入到滑动菜单(DrawerLayout)中。

首先在 now.xml 布局文件中添加一个用于打开滑动菜单的按钮。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ...>

    <Button
        android:id="@+id/navBtn"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:background="@drawable/ic_home"
        app:layout_constraintBottom_toBottomOf="@id/placeName"
        app:layout_constraintEnd_toStartOf="@id/placeName"
        app:layout_constraintHorizontal_bias="0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="@id/placeName" />

    ...

</androidx.constraintlayout.widget.ConstraintLayout>

其中的图标,你可以在 res/drawable-xxhdpi 下创建 ic_home.xml 文件。

xml 复制代码
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="960"
    android:viewportHeight="960"
    android:tint="?attr/colorControlNormal">
  <path
      android:fillColor="@android:color/white"
      android:pathData="M240,760L360,760L360,520L600,520L600,760L720,760L720,400L480,220L240,400L240,760ZM160,840L160,360L480,120L800,360L800,840L520,840L520,600L440,600L440,840L160,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z"/>
</vector>

或是从 Google Fonts 获取。

activity_weather.xml 布局中加入滑动菜单和承载 PlaceFragment 的容器。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.core.widget.NestedScrollView
            android:id="@+id/weatherLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:overScrollMode="never"
            android:scrollbars="none"
            android:visibility="visible">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <include
                    android:id="@+id/nowLayout"
                    layout="@layout/now" />

                <include
                    android:id="@+id/forecastLayout"
                    layout="@layout/forecast" />

                <include
                    android:id="@+id/lifeIndexLayout"
                    layout="@layout/life_index" />

            </LinearLayout>
        </androidx.core.widget.NestedScrollView>
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:background="?attr/colorSurface"
        android:clickable="true"
        android:focusable="true">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/placeFragment"
            android:name="com.sunnyweather.android.ui.place.PlaceFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="25dp" />
    </FrameLayout>

</androidx.drawerlayout.widget.DrawerLayout>

接着,在 WeatherActivity 中增加滑动菜单的逻辑。

kotlin 复制代码
// 在 WeatherActivity.kt 的 onCreate 方法中
override fun onCreate(savedInstanceState: Bundle?) {
    ...

    binding.nowLayout.navBtn.setOnClickListener {
        // 打开滑动菜单
        binding.drawerLayout.openDrawer(GravityCompat.START)
    }

    binding.drawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener {
        override fun onDrawerStateChanged(newState: Int) {}
        override fun onDrawerSlide(drawerView: View, slideOffset: Float) {}
        override fun onDrawerOpened(drawerView: View) {}

        // 抽屉关闭时的回调
        override fun onDrawerClosed(drawerView: View) {
            val manager = getSystemService(Context.INPUT_METHOD_SERVICE)
                    as InputMethodManager
            // 隐藏软键盘
            manager.hideSoftInputFromWindow(
                drawerView.windowToken,
                InputMethodManager.HIDE_NOT_ALWAYS
            )
        }
    })
}

另外,之前我们在 PlaceFragment 进行了一个判断:如果当前已有选中的城市,就跳转到 WeatherActivity。但此时我们将 PlaceFragment 嵌入到了 WeatherActivity,这段逻辑会造成无限循环跳转。

为此,我们来对 PlaceFragment 进行修改。

kotlin 复制代码
// PlaceFragment.kt
private fun checkSavedPlace() {
    viewModel.getSavedPlace()
    viewModel.savedPlace.observe(viewLifecycleOwner) { place ->
        // 新增条件:只有当前宿主是 MainActivity 时,才执行自动跳转
        if (place != null && activity is MainActivity) { // <-修改点
            navigateToWeather(place)
        }
    }
}

我们再来修改选中城市列表项的逻辑。之前是点击后直接跳转到 WeatherActivity,但现在如果 PlaceFragment 被嵌入到了 WeatherActivity 中,就无需跳转,只要请求城市的天气信息即可。

kotlin 复制代码
// PlaceFragment.kt

// 获取与宿主 Activity 共享的 WeatherViewModel 实例
private val sharedWeatherViewModel: WeatherViewModel by activityViewModels()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    // ...
    adapter = PlaceAdapter { place ->
        // 存储选中的城市
        viewModel.savePlace(place)

        if (activity is WeatherActivity) {
            val activity = requireActivity() as WeatherActivity

            // 关闭抽屉
            activity.closeDrawer()

            // 刷新天气信息
            sharedWeatherViewModel.refreshWeather(place)
        } else {
            // 执行跳转逻辑
            navigateToWeather(place)
        }
    }
    // ...
}

使用 activityViewModels() 方法委托需要添加依赖:

kotlin 复制代码
// app/build.gradle.kts
implementation("androidx.fragment:fragment-ktx:1.8.8")

其中,我们需要在 WeatherActivity 中创建 closeDrawer() 公有方法,用于关闭抽屉。

kotlin 复制代码
// 公共方法,用于让 Fragment 请求关闭抽屉
fun closeDrawer() {
    binding.drawerLayout.closeDrawers()
}

由于当前天气页地点的数据来源不止一种,可能由 Intent 传入,也可能是用户在抽屉中选择的。为此,我们让 WeatherViewModel 持有"当前地点"这个状态。

kotlin 复制代码
class WeatherViewModel : ViewModel() {
    // ...

    // 用于触发天气刷新
    private val _currentPlace = MutableLiveData<Place>()
    val currentPlace: LiveData<Place> = _currentPlace

    val weatherLiveData = _currentPlace.switchMap { location ->
        Repository.refreshWeather(location.id, DEFAULT_QUERY_DAYS)
    }

    /**
     * 为指定地点加载天气
     */
    fun refreshWeather(place: Place) {
        _currentPlace.value = place
    }
}

删除 WeatherActivity 中的 currentPlace 变量。

kotlin 复制代码
class WeatherActivity : AppCompatActivity() {

    // ...

    // 从Intent传入的Place对象
    // private var currentPlace: Place? = null 不再需要了

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        // 从 Intent 中获取初始地点信息
        val initialPlace = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            intent.getParcelableExtra("key_place", Place::class.java)
        } else {
            @Suppress("DEPRECATION")
            intent.getParcelableExtra("key_place")
        }


        // 触发首次数据加载
        if (initialPlace != null) {
            viewModel.refreshWeather(initialPlace)
        } else {
            Toast.makeText(this, "获取地点信息失败", Toast.LENGTH_SHORT).show()
            finish()
            return
        }

        viewModel.currentPlace.observe(this) { place ->
            //更新地点名称
            binding.nowLayout.placeName.text = place.name
        }

    }

    /**
     * 将 Weather 对象的数据填充到界面上
     */
    @SuppressLint("SetTextI18n")
    private fun showWeatherInfo(weather: Weather) {
        // binding.nowLayout.placeName.text = currentPlace?.name 删除这一行
        // ...
    }


    /**
     * 刷新天气信息
     */
    private fun refreshWeather() {
        binding.swipeRefreshLayout.isRefreshing = true
        // 发起刷新
        viewModel.currentPlace.value?.let { viewModel.refreshWeather(it) }
    }

}

现在切换城市的功能就完成了。

生成正式签名的APK文件

之前我们都是通过 Android Studio 来将应用安装到手机的。Android Studio 实际上会将代码打包成 APK 文件(应用程序的安装包),再将这个文件传到手机上进行安装。

但 Android 系统要求只有签名后的 APK 文件才能安装,而这一步 Android Studio 自动帮我们完成了:使用了默认的 keystore 文件帮我们签名。

不过在开发阶段这样做可以,要正式发布应用的话,就需要使用正式的 keystore 文件进行签名。我们来看看如何生成带有正式签名的 APK 文件。

使用 Android Studio 生成

点击导航菜单中的 Build->Generate Signed Bundle/APK。

选择 APK 即可,因为 Android App Bundle 是用于上架 Google 商店的,虽说可以减少 App 安装包体积,但无法直接安装到手机上。

因为我们还没有 keyStore 文件,点击 Create New 按钮创建一个。

尽量填写即可,然后点击确定。

点击下一步,构建类型选择 release。

点击创建,过一会,APK 文件就会生成。你可以将这个文件发给别人,别人可以直接安装到手机上。

使用 Gradle 生成

我们还可以使用 Gradle 生成 APK 文件,在 app/build.gradle.kts 文件中添加如下内容:

kotlin 复制代码
android {
    // 签名配置
    signingConfigs {
        // 创建签名配置
        create("config") {
            storeFile = file("D:\Code\AndroidCode\SunnyWeather\sunnyweather.jks")
            storePassword = "123456"
            keyAlias = "coldraindev"
            keyPassword = "123456"
        }
    }

    // 构建类型配置
    buildTypes {
        // 对 release 构建类型进行配置
        release {
            // 是否启用代码混淆和压缩
            isMinifyEnabled = false
            // 指定混淆规则文件
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            // 使用上面创建的签名配置
            signingConfig = signingConfigs.getByName("config")
        }
    }
}

然后点击右侧的 Gradle,再点击执行 Gradle 任务按钮,输入 gradlew assembleRelease 命令即可。

当命令执行完成后,已签名的 APK 文件会自动生成在 app/build/outputs/apk/release/ 目录下。

最后注意:签名配置相关信息不要硬编码build.gradle.kts 文件中,应该将这些敏感信息放在项目根路径的 local.properties 文件中,然后在 build.gradle.kts 中读取。

像这样:

Properties 复制代码
# local.properties 这个文件通常不会上传到代码仓库
KEYSTORE_FILE=D:\Code\AndroidCode\SunnyWeather\sunnyweather.jks
KEY_ALIAS=coldraindev
KEY_PASSWORD=123456
STORE_PASSWORD=123456
kotlin 复制代码
// app/build.gradle.kts

val localProperties = Properties().apply {
    val localPropertiesFile = rootProject.file("local.properties")
    if (localPropertiesFile.exists()) {
        load(FileInputStream(localPropertiesFile))
    }
}

// 签名配置
signingConfigs {
    create("config") {
        storeFile = file(localProperties.getProperty("KEYSTORE_FILE"))
        storePassword = localProperties.getProperty("STORE_PASSWORD")
        keyAlias = localProperties.getProperty("KEY_ALIAS")
        keyPassword = localProperties.getProperty("KEY_PASSWORD")
    }
}

这样一来,在 Git 中直接查看 build.gradle.kts 文件,是无法看到 keystore 文件的各种信息的,只有在本地的local.properties 文件中才能看到,也就避免了 keystore 文件信息泄露的问题。

相关推荐
程序视点1 小时前
Escrcpy 3.0投屏控制软件使用教程:无线/有线连接+虚拟显示功能等
android
东京老树根3 小时前
Android - 用Scrcpy 将手机投屏到Windows电脑上
android
Wgllss5 小时前
完整烟花效果,Compose + 协程 + Flow + Channel 轻松实现
android·架构·android jetpack
扛麻袋的少年5 小时前
6.Kotlin的Duration类
android·开发语言·kotlin
独自破碎E5 小时前
得物25年春招-安卓部分笔试题1
android
雨白5 小时前
Android 自定义 View:精通文字的测量与高级排版
android
Jasonakeke6 小时前
【重学MySQL】八十八、8.0版本核心新特性全解析
android·数据库·mysql
一条上岸小咸鱼8 小时前
Kotlin 类型检查与转换
android·kotlin
闲暇部落8 小时前
android studio配置 build
android·android studio·build
_祝你今天愉快9 小时前
Android FrameWork - Zygote 启动流程分析
android