下一个例子是一个显示天气预报的赢博体育程序。预报数据将通过openweathermap.org API下载。
赢博体育程序的第一个屏幕显示的是用户选择的地点的天气预报。
第二个屏幕允许用户输入他们想要查看的位置的邮政编码。
当赢博体育程序启动时,它将向后端web服务发送请求,以获取用户选择的位置的当前预测。赢博体育程序需要做的一件事是将用户选择的位置存储在用户的设备上,这样我们就可以在启动时查找正确的位置信息发送给web服务。
管理位置信息的类在location中定义。kt文件。
package edu.lawrence.androidweather import android.content.Context import android.content.SharedPreferences数据类GeoLocation(val zip: String, val name: String, val lat: Double, val lat: Double, val country: String)数据类Location(val zip: String, val latitude: Float, val longitude: Float, val name: String)对象LocationPreferences {private lateinit var sharedPreferences: sharedPreferences fun init(context:{sharedPreferences = Context. getsharedpreferences ("edu.lawrence.AndroidWeather",Context. mode_private)} fun getLocation(): Location {val zip: String = sharedPreferences. getstring ("zip","54911")!!val纬度=共享偏好。getFloat("latitude", 44.2619F) val longitude = sharedPreferences.getFloat("longitude",-88.4154F) val name: String = sharedPreferences.getString("name"," applpleton ")!return Location(zip,latitude,longitude,name)} fun setLocation(Location: Location) {with(sharedPreferences.edit()) {putString("zip", Location .zip) putFloat("latitude", Location .latitude) putFloat("longitude", Location .longitude) putString("name", Location .name) apply()}}
赢博体育程序将使用Location data类来表示位置信息。GeoLocation类是一个数据类,当我们按邮政编码搜索位置时,web服务将发送给我们。
LocationPreferences类管理本地位置信息的读写。我没有使用通常的class关键字来设置这个类,而是选择使用object关键字。这个关键字用于声明一个将是单例的类。当您使用object声明一个类时,Kotlin将创建该类的单个全局实例,您可以使用类名访问该实例。
LocationPreferences类包含使用共享首选项读取和写入位置信息的方法。Android上的共享偏好机制是一个简单的机制,允许赢博体育程序存储简单的键、值对。在上面getLocation()和setLocation()方法的代码中,您可以看到我们如何从共享首选项中写入和读取键值对。
这个赢博体育程序将与openweathermap.org提供的网络服务进行通信。更具体地说,我们将使用该web服务提供的url运行两个GET请求。Android平台上有许多方法可用于处理这类请求。Android赢博体育程序中没有一个“官方”的方式来发送HTTP请求,但一个非常流行的选择是使用Retrofit库来发送这些请求。
Retrofit要求我们做的第一件事是建立一个界面,列出我们想要与后端进行的各种交互。该接口的代码出现在WeatherApiService中。kt文件:
导入retrofit2.http。GET import retrofit2.http。查询接口WeatherApiService {@GET(“data/3.0/onecall”)suspend fun getForecast(@Query(“lat”)纬度:Float, @Query(“lon”)经度:Float, @Query(“appid”)key: String,@Query(“exclude”)exclude: String =“current,minute,hour,alerts”,@Query(“units”)units: String =“imperial”):Forecast @GET(“geo/1.0/zip”)suspend fun getLocation(@Query(“zip”)zip: String,@Query(“appid”)key: String): GeoLocation}
Retrofit使用注解系统在接口中为我们想要进行的每个不同的交互设置方法。从这里的注释中可以看到,我们定义了两个方法,它们将向后端发送GET请求。方法签名指定哪些信息将作为请求的一部分发送到服务器,以及我们期望服务器在响应中向我们发送什么类型的对象。我们已经看到了GeoLocation类的定义。在下面的代码中,您将看到如何定义Forecast类。
web服务将向我们发送JSON编码的对象。我们唯一需要注意的是确保Kotlin类的结构与来自服务器的JSON代码中显示的结构完全匹配。
同样,在这些示例中,我们将向包含许多查询参数的web服务发送请求。这些参数将显示为接口中列出的方法的参数。@Query注释告诉Retrofit如何将每个方法参数映射到GET请求URL中的查询参数。
在我们设置了这个接口之后,下一步是运行一些代码来设置Retrofit,然后启动请求。该代码出现在一个单独的类中,我将在下一节中介绍这个类。
同样,我们将在这个赢博体育程序中使用视图模型类。这一次的一个细微差别是,视图模型将不再充当赢博体育程序数据的容器;相反,它将充当web服务的网关,而web服务将成为我们赢博体育程序数据的最终来源。
WeatherModel。Kt文件包含我们将用于与web服务通信的视图模型类的定义。以下是该文件的完整代码:
导入androidx.lifecycle.ViewModel导入androidx.lifecycle.viewModelScope导入kotlinx.coroutines.Dispatchers导入kotlinx.coroutines.launch导入kotlinx.coroutines.withContext导入retrofit2。导入retrofit2.converter.gson。GsonConverterFactory数据类TempRange(val day: Double, val min: Double?)瓦尔·马克斯:双份?数据类天气(val id: Int, val main: String, val description: String, val icon: String)数据类ForecastDay(val dt: Int, val sunrise: Int, val sunset: Int, val moonrise: Int, val moonset: Int, val moon_phase: Double, val temp: TempRange, val feels_like: TempRange, val pressure: Int, val humidity: Int, val dew_point: Double, val wind_speed: Double, val wind_deg: Int, val wind_gust: Double, val Weather:)列表<天气>,val云:Int, val流行:Double, val雨:Double?, val uvi: Double)数据类预测(val lat: Double, val lon: Double, val timezone: String, val timezone_offset: Int, val daily: List<ForecastDay>)类WeatherModel: ViewModel() {private lateinit var restInterface: WeatherApiService val location = mutableStateOf(LocationPreferences.getLocation()) val Forecast = mutableStateOf<Forecast?>(null) init {val retrofit:Retrofit = Retrofit. builder () . addconverterfactory (GsonConverterFactory.create()) . baseurl ("https://api.openweathermap.org/") .build() restInterface = Retrofit. create(WeatherApiService::class.java)} fun getForecast() {val latitude = location.value.latitude val longitude = location.value.longitude viewModelScope.launch(Dispatchers.IO) {try {val result = restInterface. builder ()getForecast(纬度=纬度,经度=经度,键= KeyHolder。apikey) withContext(Dispatchers.Main){预报。value = result}} catch (e:Exception) {e.p printstacktrace ()}}} fun setLocationZip(zip: String) {viewModelScope.launch(Dispatchers.IO) {try {val result = restInterface。getLocation(zip=zip+“,US”,key = KeyHolder.apikey) val newLocation = Location(zip=zip,纬度= result.lat.toFloat(),经度= result.lon.toFloat(), name = result.name) LocationPreferences.setLocation(newLocation) withContext(Dispatchers.Main) {Location . zip= “,US”,key = KeyHolder.apikey)value = newLocation}} catch (e:Exception) {e. printstacktrace ()}}}}
该文件以许多数据类定义开始,这些定义定义了web服务可能发送给我们的对象类型。不幸的是,web服务将为响应我们的预测请求而发回的数据非常复杂,并且由层次结构对象的复杂集合组成。在响应的顶层,我们将获得一个Forecast对象,它包含一个ForecastDay对象列表,这些对象又具有复杂的层次结构。
这个文件中最重要的类定义是WeatherModel类。这是我们赢博体育程序的视图模型。这次我们将使用更简单的方法来设置视图模型类,也就是让它从ViewModel继承。
我们在上面看到,Retrofit希望我们做的第一件事是设置指定服务器交互如何工作的接口。我们的下一步是要求Retrofit给我们一个实现该接口的对象。该对象将存储在restInterface成员变量中。初始化该成员变量的代码出现在init()方法中。
getForecast()和setLocationZip()方法运行服务器交互。Android的一个重要规则是,赢博体育服务器交互都需要在后台运行。为了在后台运行一段代码,我们将使用Kotlin的协程工具。要启动一个协程,我们需要一个协程作用域和一个调度程序。分派器是一个对象,它维护可以运行协同程序代码的线程池。在本例中,我们将使用两个调度程序,IO和Main。IO调度程序被设计为与远程服务器通信,而Main调度程序运行与赢博体育程序的主UI线程交互的代码。要运行协程,我们使用适当作用域的launch()方法。因为我们正在为ViewModel类编写代码,所以我们可以利用ViewModel对象中自动可用的viewModelScope。launch()方法的最后一个参数是包含协程代码的lambda表达式。
您可以在上面的getForecast()方法中看到一个协程示例。该协程使用restInterface向服务器发送get请求,该请求将向我们返回一个Forecast对象。这里的视图模型类还包含一个存储Forecast的状态变量。我们希望将从服务器返回的Forecast对象复制到该状态变量中。在做这件事的时候我们必须小心一点,因为Android的另一个规则是状态变量只能由UI线程上运行的代码更新。为了正确实现这一点,我们必须使用withContext()构造将更新状态变量的代码切换到Main调度程序。
现在我们已经为赢博体育程序设置了一些基本的基础设施,最后是时候为赢博体育程序本身编写代码了。赢博体育这些代码都出现在MainActivity中。kt文件。
package edu.lawrence.androidweather import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import edu.lawrence.androidweather.ui.theme.AndroidWeatherTheme import java.time.Instant import java.time.Instant.ofEpochSecond import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) LocationPreferences.init(this) enableEdgeToEdge() setContent { AndroidWeatherTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> WeatherApp( modifier = Modifier.padding(innerPadding) ) } } } } } @Composable fun WeatherApp(modifier : Modifier) { val vm : WeatherModel = viewModel() vm.getForecast() val navController = rememberNavController() NavHost(navController,startDestination="forecast") { composable(route="forecast") { WeatherScreen(vm,toOptions = { navController.navigate("location")}, modifier = modifier) } composable(route="location") { LocationScreen(vm,back = { navController.navigate("forecast")}, modifier = modifier) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun WeatherScreen(vm : WeatherModel, toOptions: () -> Unit,modifier: Modifier) { Scaffold(topBar = { TopAppBar( title = { Text( "Android Weather", ) }, actions = { IconButton(onClick = toOptions) { Icon( imageVector = Icons.Default.Settings, contentDescription = "Settings" ) } }, ) }, modifier = Modifier.fillMaxSize()) { innerPadding -> ForecastView(vm,modifier.padding(innerPadding)) } } @Composable fun ForecastView(vm : WeatherModel,modifier:Modifier) { val forecast = remember { vm.forecast } Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxSize()) { Text(vm.location.value.name) HorizontalDivider() if(forecast.value == null) { Text("Loading...") } else { val days = forecast.value!!.daily val offset = forecast.value!!.timezone_offset.toLong() Column { days.forEach { day -> ForecastDayCard(offset,day) } } } } } @Composable fun ForecastDayCard(offset: Long, day : ForecastDay) { Card() { Row() { Column() { val time : Instant = ofEpochSecond(day.dt + offset) val formatter = DateTimeFormatter.ofLocalizedDate( FormatStyle.LONG ) .withLocale(Locale.US) .withZone( ZoneOffset.UTC) val date : String = formatter.format(time) Text(date) Text("High ${day.temp.max} Low ${day.temp.min}") } Icon(imageVector = nameToIcon(day.weather.get(0).icon), contentDescription = "Weather Icon") } } } @Composable fun LocationScreen(vm : WeatherModel,back : () -> Unit, modifier : Modifier) { val focusManager = LocalFocusManager.current val zip = remember { mutableStateOf("") } val location = remember { vm.location} Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = modifier.fillMaxSize()) { Row(horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Bottom) { Text("ZIP Code") Spacer(modifier=Modifier.width(10.dp)) TextField(value = zip.value, onValueChange = {newValue : String -> zip.value = newValue}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() }) ) } Spacer(modifier=Modifier.height(20.dp)) Row() { Button(onClick = {vm.setLocationZip(zip.value)}) { Text("Search") } } Spacer(modifier=Modifier.height(20.dp)) Row() { Text("Latitude") Spacer(modifier = Modifier.width(10.dp)) Text(location.value.latitude.toString()) } Row() { Text("Longitude") Spacer(modifier = Modifier.width(10.dp)) Text(location.value.longitude.toString()) } Row() { Text("City") Spacer(modifier = Modifier.width(10.dp)) Text(location.value.name) } Spacer(modifier=Modifier.height(20.dp)) Button(onClick = { vm.getForecast() back() }) { Text("Done") } } }
这里的整体结构遵循我在前面示例中使用的赢博体育程序架构。WeatherApp组件为两个屏幕之间的导航设置了一个NavHost。这两个屏幕由WeatherScreen和LocationScreen组件定义。WeatherScreen设置了一个Scaffold,其中包含一个导航栏和一个承载ForecastView组件的内容区域。ForecastView显示一个预报日列表,每个预报日都显示在ForecastDayCard组件中。
WeatherApp中的一行代码尤为重要:
val vm: WeatherModel = viewModel()
这行代码负责设置视图模型类并将其存储在一个局部变量中。那个视图模型然后被传递给赢博体育程序层次结构中的其他组件,这样它们就可以在需要时与视图模型通信。视图模型的一个特点是,我们必须调用一个特殊的viewModel()方法,该方法将设置视图模型对象并将其返回给我们。这段代码假设我们在赢博体育程序中只定义了一个视图模型类:该方法将定位那个视图模型类,创建那个类的实例,然后返回给我们。
为了解决我所使用的网络服务带来的一些技术问题,我必须做很多事情来整合这个赢博体育程序。web服务造成了一些问题,因为他们在表示预报中发送的关键信息时做出了一些选择。我必须解决两个具体问题:
如果您想看看我是如何处理这两个问题的,您可以在ForecastDayCard组件中看到处理问题1的代码,在Icons中看到代码。Kt文件来处理问题2。