65.9K
CodeProject 正在变化。 阅读更多。
Home

天气预报

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2020年6月12日

CPOL

19分钟阅读

viewsIcon

16884

downloadIcon

218

世界上最好的天气预报应用(这是重点!)。

引言

接下来是我对一个小型、易读、简洁、无广告的天气预报应用程序的探索。它也没有广告!我最初的列表中没有一项是创建YAWA(有Unix背景的你将会理解这个缩写)。应用商店Play store里已有数十款更好的应用,功能更强大,还包含雷达图像。

背景

我多年前就开始着手这个项目,那时 Weather Underground 还没有被 IBM 收购。我几乎完成了这个项目,但不得不暂停,将注意力转移到别的地方。我还需要对用户界面做一点打磨,并在各种(模拟的)设备上进行更多的测试。去年我再次拾起它时,立刻发现它无法工作了。很快,我发现免费的 Weather Underground API 已经消失了。真糟糕。

搜索新的API,我找到了 AccuWeather。除了请求天气数据的URL格式略有不同之外,API的使用方式非常相似。最大的区别(至少对我来说)在于获取天气数据所需的请求次数。使用 Weather Underground 时,URL同时包含位置(例如,邮政编码)和所需的预报类型。然而,使用 AccuWeather 时,这两个项目是独立的URL。第一次请求将位置(例如,邮政编码、城市)“转换”为位置键。然后,该键是第二次请求中获取实际预报的一部分。这不是一个重大变化,但确实是一个变化。

为这个项目创建的第一个UI是主/明细形式,它为智能手机和为平板电脑显示不同的方式。这,再加上想要一个方便的方法在5天和12小时预报之间切换,使得代码难以理解,更不用说维护了。你的感受可能不同。然后我切换到2个标签页的设计,一个标签页包含5天预报,另一个标签页包含12小时预报。它们可以独立运行,标签页中的每个项目点击后都会打开一个详细的活动。我更喜欢这种方法。

我将在本文中讨论的重点是

  1. 请求设备当前位置,
  2. 使用 Google 的 Places API 搜索设备当前位置以外的地点,
  3. 下载和解析 JSON 文件,
  4. 将天气预报数据存储在数据库中,以及
  5. 使用 Loader 监视这些数据的变化。

最终,我得到的是一个易于使用的应用程序,体积很小(我还在学习如何从 Android Studio 生成的 APK 文件中删除不必要的文件),没有广告依赖,并且权限要求最低。

Using the Code

请求设备当前位置

要检索您当前位置的天气预报(请参见工具栏中的我的位置按钮),首先必须获得运行时权限。我们首先检查权限是否已被授予。如果已授予,我们将继续进行位置检索。如果没有,我们将请求它,然后等待结果。如果该请求在结果中被授予,我们就可以继续进行位置检索。此代码如下所示

mPermissionGranted = (ActivityCompat.checkSelfPermission
           (this, Manifest.permission.ACCESS_FINE_LOCATION) == PERMISSION_GRANTED);
if (mPermissionGranted)
    requestCurrentLocation();
else
    ActivityCompat.requestPermissions(this, new String[]
             { Manifest.permission.ACCESS_FINE_LOCATION }, 1);

如果我们必须请求权限,权限请求的结果将在 onRequestPermissionsResult() 中返回,如下所示

if (requestCode == 1)
{
    if (grantResults.length == 1)
    {
        mPermissionGranted = (grantResults[0] == PERMISSION_GRANTED);
        requestCurrentLocation();
    }
}
else
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);

现在权限已授予,我们可以提交请求以获取设备的位置。这是通过创建一个 LocationRequest 对象(将其参数设置为“立即,实时”)来完成的,创建一个 FusedLocationProviderClient 的新实例,并调用其 requestLocationUpdates() 方法,如下所示

if (mPermissionGranted)
{
    try
    {
        LocationRequest locationRequest = new LocationRequest();
        locationRequest.setInterval(1000); // 1 second
        locationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

        mFusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
        mLocationCallback = new MyLocationCallback();
        mFusedLocationClient.requestLocationUpdates
               (locationRequest, mLocationCallback, Looper.myLooper());
    }
    catch (Exception e)
    {
        Log.e(TAG, "  Error getting my location: " + e.getMessage());
    }
}

请注意用于接收位置请求的回调(类)。传递给它的 onLocationResult() 方法是一个 LocationResult 对象,我们可以用它来获取最后已知的位置。从中,我们得到一个 Location 对象,该对象包含设备的纬度/经度坐标等。此代码如下所示

public void onLocationResult( LocationResult locationResult )
{
    // kill future requests
    if (mFusedLocationClient != null)
    {
        mFusedLocationClient.removeLocationUpdates(mLocationCallback);
        mFusedLocationClient = null;
    }

    Location loc = locationResult.getLastLocation();
    if (loc != null)
    {
        SharedPreferences.Editor editor = prefs.edit();

        double lat = loc.getLatitude();
        editor.putFloat("Latitude", (float) lat);
        double lng = loc.getLongitude();
        editor.putFloat("Longitude", (float) lng);
        editor.commit();
    }
    else
        Log.d(TAG, "  Unable to get last location");
}

一旦获取了位置,就可以关闭位置更新,以免不必要地消耗设备电池。单击“我的位置”按钮将重复此请求/移除过程。

此时,纬度/经度坐标有两个用途

  1. 它们是有关所请求位置的基本信息的一部分,并且
  2. 它们作为我们用于获取所请求位置的地址信息的 Geocoder API 的输入(即,反向地理编码)。

关于 Geocoder API 的详细讨论有很多,所以我不会在这里重复。我只展示了如何使用它来获取这些感兴趣的片段:城市、州和邮政编码。邮政编码也用作 AccuWeather API 的搜索文本。此代码如下所示

Geocoder geoCoder = new Geocoder(MainActivity.this, Locale.getDefault());
List<Address> addresses = geoCoder.getFromLocation(lat, lng, 1);
if (addresses != null && ! addresses.isEmpty())
{
    String s = addresses.get(0).getLocality();
    editor.putString("City", s);
    s = addresses.get(0).getAdminArea();
    editor.putString("State", s);
    s = addresses.get(0).getPostalCode();
    editor.putString("ZIP", s);

    // only bother updating the preference (and retrieving forecasts) 
    // if the SearchText changed
    String strSearchText = prefs.getString("SearchText", "");
    if (! addresses.get(0).getPostalCode().equals(strSearchText))
    {
        strSearchText = addresses.get(0).getPostalCode();
        editor.putString("SearchText", strSearchText); // this is being monitored by 
                                                       // DayFragment and HourFragment
    }
}
else
    Log.d(TAG, "  getFromLocation() returned null/empty");

使用 Google 的 Places API

在该项目的过程中,我想添加一个搜索功能,并且我正在考虑如何处理它。我希望某个地方存在一个大型数据库,其中包含所有已知的位置、地点、地标等。它会是什么样子?我将如何与它交互?这些只是我突然冒出来但没有答案的一些问题。然后我偶然发现了 Google 的 Places API。有关如何使用自动完成服务的详细信息可以在此处找到。我选择了选项 2,它使用一个 Intent 来启动自动完成活动。首先要做的是指定活动返回后我们感兴趣的字段。其次是使用 IntentBuilder() 方法创建意图。由于我们对该意图的返回值(即,选定的地点)感兴趣,最后一件事是调用 startActivityForResult()。这看起来像

List<Place.Field> fields = Arrays.asList(Place.Field.ADDRESS, 
                           Place.Field.ADDRESS_COMPONENTS, Place.Field.LAT_LNG);
Intent intentSearch = new Autocomplete.IntentBuilder
                      (AutocompleteActivityMode.FULLSCREEN, fields).build(this);
startActivityForResult(intentSearch, 1);

当自动完成活动返回时,我们会在 onActivityResult() 方法中收到通知。如果 resultCode 等于 RESULT_OKrequestCode 与我们传递给 startActivityForResult() 的代码匹配,我们就可以从返回的意图中提取必要的信息。此代码如下所示

SharedPreferences.Editor editor = prefs.edit();

Place place = Autocomplete.getPlaceFromIntent(data);
LatLng latlng = place.getLatLng();
editor.putFloat("Latitude", (float) latlng.latitude);
editor.putFloat("Longitude", (float) latlng.longitude);

String sSearchText = place.getAddress();
editor.putString("SearchText", sSearchText); // this is being monitored by 
                                             // DayFragment and HourFragment
editor.commit();

虽然以上三条信息到目前为止对于我搜索过的每个地点都存在,但其他与地点相关的信息,如城市、州和邮政编码,则不存在。例如,搜索迈阿密、佛罗里达州会产生一个 AddressComponents 数组,如下所示

AddressComponents{asList=[AddressComponent{name=Miami, shortName=Miami, 
                          types=[locality, political]}, 
                          AddressComponent{name=Miami-Dade County, 
                          shortName=Miami-Dade County, types=[administrative_area_level_2, 
                          political]}, 
                          AddressComponent{name=Florida, shortName=FL, 
                          types=[administrative_area_level_1, political]}, 
                          AddressComponent{name=United States, shortName=US, 
                          types=[country, political]}]}

要提取各个片段,我们采用类似的方法

List<AddressComponent> list = place.getAddressComponents().asList();
for (int x = 0; x < list.size(); x++)
{
    AddressComponent ac = list.get(x);
    List<String> types = ac.getTypes();
    for (int y = 0; y < types.size(); y++)
    {
        String type = types.get(y);
        if (type.equals("postal_code"))
        {
            String sZIP = ac.getName();
            editor.putString("ZIP", sZIP);
            editor.commit();
        }
        else if (type.equals("locality"))
        {
            String sCity = ac.getName();
            editor.putString("City", sCity);
            editor.commit();
        }
        else if (type.equals("administrative_area_level_1"))
        {
            String sState = ac.getName();
            editor.putString("State", sState);
            editor.commit();
        }
    }
}

此时,已经搜索了一个地点或请求了当前地点。在这两种情况下,SearchText 首选项都已添加或更改。此更改正在两个地方进行监视:一个在 DayFragment 中,另一个在 HourFragment 中。这两个片段都注册了一个回调,当首选项发生更改时,该回调会被调用,如下所示 DayFragment

prefs = PreferenceManager.getDefaultSharedPreferences(activity);
prefs.registerOnSharedPreferenceChangeListener(this);

@Override
public void onSharedPreferenceChanged( SharedPreferences sharedPreferences, String key )
{
    if (key.equals("SearchText")) // if a different location was requested, retrieve it
        mDayForecast.retrieveForecasts();
}

现在,当 SearchText 首选项添加或更改时,每个片段都可以开始检索新位置的预报。

下载和解析 JSON 文件

预报检索过程始于 DayForecastIOHourForecastIOretrieveForecasts() 方法。由于它们只在用于检索预报的 URL 和从 JSON 文件中提取的字段方面有所不同,为了简洁起见,我将展示前者的代码片段。

为了避免不必要地占用 UI 线程,retrieveForecasts() 方法将所有内容包装在一个辅助线程中。该线程下载 JSON 文件,然后对其进行处理。

我之前提到过,我们需要一个位置键来获取我们想要检索预报的位置。这种“转换”也来自 AccuWeather API,并以 JSON 文件返回。在该项目的过程中,我注意到我的每个预报请求都消耗了 AccuWeather API 的四次调用(对于 5 天和 12 小时,一次请求获取位置键,第二次请求获取该位置的预报)。按此速率,我的应用程序每天只能处理大约 12 次预报请求。这对我日常的例行操作来说可能不是什么大问题,但如果我搜索其他地方,或者点击“刷新”按钮太多次怎么办?

LocationKey 类是一个 单例 类,其唯一实例在 DayForecastIOHourForecastIO 之间共享。由于 retrieveLocationKey()同步的,无论 DayForecastIOHourForecastIO 哪个先调用它,位置键都会从数据库或互联网检索,而另一个调用将阻塞然后从数据库检索。这节省了一次网络访问。

然而,在下载和解析位置 JSON 文件之前,我们首先查看数据库,看该位置是否已被搜索过(有关数据库的更多信息,请参阅下一节)。如果已搜索过,我们只需从中检索位置键。否则,我们发出请求并处理它。通过将搜索过的位置及其键存储在数据库中,预报请求现在仅消耗 AccuWeather API 的三次调用,因此我的应用程序现在每天可以处理大约 16 次请求。绰绰有余!此代码如下所示

public synchronized boolean retrieveLocationKey( String strSearchText )
{
    boolean bReturn = false;
    String where = "'" + strSearchText + "' = " + ForecastProvider.KEY_LOCATION;

    ContentResolver cr = mActivity.getContentResolver();
    Cursor query = cr.query(ForecastProvider.LOCATIONS_CONTENT_URI, null, where, null, null);
    if (query != null)
    {
        if (query.getCount() == 0)
        {
            // the location was not found, so retrieve the location key from the internet
            StringBuilder builder = downloadLocationJSON(strSearchText);
            if (builder.length() > 0)
                bReturn = parseLocationInfo(builder, strSearchText);
        }
        else
        {
            // the location was found, so retrieve its associated location key
            query.moveToFirst();
            String sLocationKey = query.getString(query.getColumnIndex
                                  (ForecastProvider.KEY_LOCATION_KEY));

            SharedPreferences.Editor editor = mPrefs.edit();
            editor.putString("LocationKey", sLocationKey);
            editor.commit();

            bReturn = true;
        }

        query.close();
    }

    return bReturn;
}

如果发现某个位置之前未被搜索过,我们转到下一步下载 JSON 文件。使用搜索文本,URL 将如下所示

http://dataservice.accuweather.com/locations/v1/search?apikey=[API_KEY]&q=Redmond%2CWA

private StringBuilder downloadLocationJSON( String strSearchText )
{
    StringBuilder builder = new StringBuilder();

    try
    {
        URL url = new URL(Utility.getLocationUrl(mPrefs, strSearchText));
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        if (connection.getResponseCode() == HttpURLConnection.HTTP_OK)
        {
            String strLine;

            BufferedReader reader = new BufferedReader(new InputStreamReader
                                    (connection.getInputStream()));
            while ((strLine = reader.readLine()) != null)
                builder.append(strLine);

            reader.close();
            connection.disconnect();
        }
        else
        {
            if (connection.getResponseCode() == HttpURLConnection.HTTP_UNAVAILABLE)
                Utility.showSnackbarMessage(mActivity, 
                        mActivity.getResources().getString(R.string.too_many_requests));
            else if (connection.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED)
                Utility.showSnackbarMessage(mActivity, 
                        mActivity.getResources().getString(R.string.invalid_api_key));
        }
    }
    catch(UnknownHostException uhe)
    {
        Utility.showSnackbarMessage(mActivity, mActivity.getResources().getString
                                   (R.string.no_network_connection));
    }
    catch (Exception e)
    {
        Log.e(TAG, "  Error downloading Location JSON file: " + e.getMessage());
    }

    return builder;
}

出于 while() 循环内部的性能原因,从 JSON 文件读取的行会被附加到 StringBuilder 对象而不是 String 连接。由于 JSON 文件旨在用于数据传输而不是 UI 表示,因此数据流可能不包含 \r\n 字符,因此 while() 循环很可能只执行一次。

从上面的下载代码返回的 JSON 文件将有两种非常相似的格式之一,具体取决于搜索内容。如果搜索文本是城市,JSON 文件看起来如下,位置键在 Key 键中

[
  {
    "Version": 1,
    "Key": "341347",
    "Type": "City",
    "Rank": 55,
    "LocalizedName": "Redmond",
    "EnglishName": "Redmond",
    "PrimaryPostalCode": "98052",
    "Region": {
      "ID": "NAM",
      "LocalizedName": "North America",
      "EnglishName": "North America"
    },
    "Country": {
      "ID": "US",
      "LocalizedName": "United States",
      "EnglishName": "United States"
    },
    "AdministrativeArea": {
      "ID": "WA",
      "LocalizedName": "Washington",
      "EnglishName": "Washington",
      "Level": 1,
      "LocalizedType": "State",
      "EnglishType": "State",
      "CountryID": "US"
    },
  }
  ...
]

如果搜索文本是邮政编码,JSON 文件看起来如下,位置键在 ParentCity/Key 键中。当我在开发应用程序代码的这一部分时,我从未见过它们同时出现在同一个搜索中。

[
  {
    "Version": 1,
    "Key": "37935_PC",
    "Type": "PostalCode",
    "Rank": 55,
    "LocalizedName": "Beverly Hills",
    "EnglishName": "Beverly Hills",
    "PrimaryPostalCode": "90210",
    "Region": {
      "ID": "NAM",
      "LocalizedName": "North America",
      "EnglishName": "North America"
    },
    "Country": {
      "ID": "US",
      "LocalizedName": "United States",
      "EnglishName": "United States"
    },
    "AdministrativeArea": {
      "ID": "CA",
      "LocalizedName": "California",
      "EnglishName": "California",
      "Level": 1,
      "LocalizedType": "State",
      "EnglishType": "State",
      "CountryID": "US"
    },
    "ParentCity": {
      "Key": "332045",
      "LocalizedName": "Beverly Hills",
      "EnglishName": "Beverly Hills"
    },
  }
  ...
]

如果您关注的天气不在美国,您显然需要在这个 if() 测试中进行相应的调整。 使用 isNull() 方法测试键是否存在。因此,对于上述两种情况的解析代码如下

String sLocationKey = "";
int nOffset = 0;
String sTZName = "";

JSONArray array = new JSONArray(builder.toString());
for (int x = 0; x < array.length(); x++)
{
    JSONObject obj = array.getJSONObject(x);

    // in case our search can be found in multiple countries, limit it to the US
    String sCountry = obj.getJSONObject("Country").getString("ID");
    if (sCountry.equals("US"))
    {
        // get TZ from here instead of going through the TimeZoneDB API
        nOffset = obj.getJSONObject("TimeZone").getInt("GmtOffset");
        sTZName = obj.getJSONObject("TimeZone").getString("Code");

        // the location key can be found in one of two spots, 
        // depending on what was searched for
        if (! obj.isNull("ParentCity"))
        {
            sLocationKey = obj.getJSONObject("ParentCity").getString("Key");
            break;
        }
        else if (! obj.isNull("Key"))
        {
            sLocationKey = obj.getString("Key");
            break;
        }
    }
}

一旦找到任何一个位置键,就没有必要仔细研究 JSON 文件的其余部分了,所以 for() 循环会退出。现在我们有了一个位置键,它可以被保存到首选项和数据库中,如下所示

SharedPreferences.Editor editor = mPrefs.edit();
editor.putString("LocationKey", sLocationKey);
editor.putInt("TimeZoneOffset", nOffset);
editor.putString("TimeZoneName", sTZName);
editor.commit();

// store this location/location key pair for retrieval later on, saving an internet trip
ContentValues values = new ContentValues();
values.put(ForecastProvider.KEY_LOCATION, sSearchText);
values.put(ForecastProvider.KEY_LOCATION_KEY, sLocationKey);

String where = "'" + sSearchText + "' = " + ForecastProvider.KEY_LOCATION;
ContentResolver cr = mActivity.getContentResolver();

Cursor query = cr.query(ForecastProvider.LOCATIONS_CONTENT_URI, null, where, null, null);
if (query != null)
{
    if (query.getCount() == 0)
        cr.insert(ForecastProvider.LOCATIONS_CONTENT_URI, values);
    else
        cr.update(ForecastProvider.LOCATIONS_CONTENT_URI, values, where, null);

    query.close();
}

insert() 方法的调用在下面的 ForecastProvider 类中进行了讨论。

还有两个 JSON 文件需要下载和解析:一个是 5 天预报,另一个是 12 小时预报。这些文件的代码与上面显示的几乎相同。区别在于使用的 URL 和提取的特定字段。

从这两个下载中,HTTP 响应头中有两个字段值得关注:RateLimit-LimitRateLimit-Remaining。使用我们的免费密钥,前者应为 50。后者将从该值开始,每次请求(无论是获取位置键还是预报)都会减少 1。一旦达到 0,HTTP 响应头将返回 HTTP_UNAVAILABLE 而不是 HTTP_OK,这意味着请求过多。在开发早期阶段,我曾多次达到这一点,但一旦开发稳定下来,很少低于 35。

DayForecastIOHourForecastIOdownloadForecastJSON() 方法中,在打开与 URL 的连接和检查响应代码之间,使用以下代码片段从 HTTP 响应头获取这两个字段。然后会弹出一个 Toast 消息,显示剩余的请求数量。

String sLimit = connection.getHeaderField("RateLimit-Limit");
String sRemaining = connection.getHeaderField("RateLimit-Remaining");
SharedPreferences.Editor editor = mPrefs.edit();
editor.putInt("RequestLimit", Integer.parseInt(sLimit));
editor.putInt("RequestRemaining", Integer.parseInt(sRemaining));
editor.commit();

使用数据库存储预报数据

我多次提到使用数据库来存储预报和位置数据。这是使用 Android 对 SQLite 的实现来完成的。我在过去几个项目中都使用过它,所以比使用建议的 Room 库更熟悉它。

有两种使用数据库的方法。一种是通过 ContentProvider 访问您的数据,另一种是直接通过 SQLiteDatabase 使用数据库。如果数据要在应用程序之间共享,则需要前者。由于预报数据不会与其他应用程序共享,因此不需要内容提供者,但我还是要使用一个。

Advanced Android Application Development (Developer's Library)》一书中的一个章节很好地解释了如何使用内容提供者来访问您应用程序的数据(以及其他应用程序的数据)。这基本上涉及从 ContentProvider 扩展一个类,定义内容提供者用来识别和与数据交互的必要 URI,定义列名,并覆盖几个方法。我们还需要一个扩展 SQLiteOpenHelperprivate 类。该类将负责创建、打开和根据需要升级底层数据库。如果我们不使用内容提供者,那么后者就是我们所需要的一切。

预报数据库有三个表。它们是

day_forecast

hour_forecast

locations

列名不言而喻。每个要显示的预报信息都有一个对应的列。虽然 day_forecasthour_forecast 表非常相似,但为了简单起见,我将它们分开。这可能违反了某种 规范化规则,但我追求的是简单而不是完全的正确性(尤其是对于个人应用程序)。

ForecastDatabaseHelper 类包含这些表名的 String 对象、用于创建它们的 SQL 语句以及由内容提供者调用的两个已覆盖方法

private static final String DATABASE_NAME       = "forecasts.db";
private static final int DATABASE_VERSION       = 1;
private static final String DAY_FORECAST_TABLE  = "day_forecast";
private static final String HOUR_FORECAST_TABLE = "hour_forecast";
private static final String LOCATIONS_TABLE     = "locations";

private static final String DAY_FORECAST_TABLE_CREATE =
    "CREATE TABLE " + DAY_FORECAST_TABLE + " ("
    + KEY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
    + KEY_FORECAST_ID + " INTEGER, "
    + KEY_FORECAST_DATE + " INTEGER, "
    + KEY_TEMPERATURE + " TEXT, "
    + KEY_FEELS_LIKE + " TEXT, "
    + KEY_FORECAST + " TEXT, "
    + KEY_ICON + " INTEGER, "
    + KEY_RAIN + " TEXT, "
    + KEY_RAIN_PROB + " INTEGER, "
    + KEY_SNOW + " TEXT, "
    + KEY_SNOW_PROB + " INTEGER, "
    + KEY_WIND_SPEED + " TEXT, "
    + KEY_WIND_DIR + " INTEGER, "
    + KEY_RISE + " INTEGER, "
    + KEY_SET + " INTEGER);";
...

//========================================================

public void onCreate( SQLiteDatabase db )
{
    try
    {
        db.execSQL(DAY_FORECAST_TABLE_CREATE);
        db.execSQL(HOUR_FORECAST_TABLE_CREATE);
        db.execSQL(LOCATIONS_TABLE_CREATE);
    }
    catch(SQLiteException e)
    {
        Log.e(TAG, "Error creating tables: " + e.getMessage());
    }
}

//========================================================

public void onUpgrade( SQLiteDatabase db, int oldVersion, int newVersion )
{
    Log.d(TAG, "Upgrading database from version " + oldVersion + " to version " + newVersion);

    try
    {
        db.execSQL("DROP TABLE IF EXISTS " + DAY_FORECAST_TABLE);
        db.execSQL("DROP TABLE IF EXISTS " + HOUR_FORECAST_TABLE);
        db.execSQL("DROP TABLE IF EXISTS " + LOCATIONS_TABLE);
    }
    catch(SQLiteException e)
    {
        Log.e(TAG, "Error dropping tables: " + e.getMessage());
    }

    onCreate(db);
}

ForecastProvider 类比上述数据库助手类更忙碌一些。它首先定义了三个用于表识别的 URI。它们是 public 作用域的,因为它们在内部以及在应用程序的其他地方使用。您需要/想要更改 URI 中的包引用。

public static final Uri DAY_FORECAST_CONTENT_URI  = 
       Uri.parse("content://com.dcrow.Forecast.provider/day_forecast");
public static final Uri HOUR_FORECAST_CONTENT_URI = 
       Uri.parse("content://com.dcrow.Forecast.provider/hour_forecast");
public static final Uri LOCATIONS_CONTENT_URI     = 
       Uri.parse("content://com.dcrow.Forecast.provider/locations");

接下来是一个 ForecastDatabaseHelper 对象,内容提供者使用它与底层数据库通信。它在 onCreate() 方法中创建,如下所示

private ForecastDatabaseHelper dbHelper;
...
public boolean onCreate()
{
    try
    {
        dbHelper = new ForecastDatabaseHelper(getContext(),
                                              ForecastDatabaseHelper.DATABASE_NAME,
                                              null,
                                              ForecastDatabaseHelper.DATABASE_VERSION);
    }
    catch(Exception e)
    {
        Log.e(TAG, "Error creating provider: " + e.getMessage());
    }

    return true;
}

dbHelper 对象用于 query()insert()delete()update() 方法,以获取可读或可写数据库对象。例如,当需要在 parseLocationInfo() 方法中的 insert() 方法上方插入新行时,会调用 insert() 方法,将表的 URI 和键/值对传递给它

public Uri insert( @NonNull Uri uri, @Nullable ContentValues values )
{
    SQLiteDatabase database = dbHelper.getWritableDatabase();
    Uri _uri = null;

    switch(uriMatcher.match(uri))
    {
        case DAY_FORECAST:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert
                          (ForecastDatabaseHelper.DAY_FORECAST_TABLE, null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(DAY_FORECAST_CONTENT_URI, lRowId);
            }

            break;
        }

        case HOUR_FORECAST:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert(ForecastDatabaseHelper.HOUR_FORECAST_TABLE, 
                                          null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(HOUR_FORECAST_CONTENT_URI, lRowId);
            }

            break;
        }

        case LOCATIONS:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert(ForecastDatabaseHelper.LOCATIONS_TABLE, null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(LOCATIONS_CONTENT_URI, lRowId);
            }

            break;
        }

        default:
            throw new SQLException("Failed to insert forecast_row into " + uri);
    }

    getContext().getContentResolver().notifyChange(_uri, null);
    return _uri;
}

switch() 语句将 URI 参数解析为实际的表名,以便调用正确的 insert() 方法。最后,调用内容解析器的 notifyChange() 方法,让任何侦听器/观察者知道已发生更改。其他三个方法大同小异,它们调用数据库助手类中的相应方法。

使用 Loader 监视和检索预报数据

我之前提到过,我开始这个项目时使用的是主/明细模型。这样做时,Android Studio 会创建 ViewModelLiveData 类来处理数据加载。我发现使用 Loader 更直观一些,所以我就坚持使用了。我知道它后来已被弃用,所以对于涉及数据存储的未来项目,我可能会使用新的方法。

在两个 UI 片段 DayFragmentHourFragment 中,使用 getActivity().getSupportLoaderManager().initLoader(...) 创建一个 Loader。这会反过来调用该片段的 onCreateLoader() 方法,该方法创建一个 CursorLoader 对象(这感觉非常像一个 SQL SELECT 语句)。此对象的创建将内部调用 ForecastProvider.query() 方法。与上面的 insert() 一样,此方法将 URI 参数解析为实际的表名,然后构建并执行查询。最后调用 setNotificationUri() 来注册一个侦听器/观察者,用于监视对作为参数传递的 URI 所做的更改。因此,当对我们的三个 URI 中的任何一个进行更改时(例如,insertdeleteupdate),任何注册的侦听器都会通过调用 notifyChange() 来收到通知。这时,onLoadFinished() 方法之一将被调用,以使用新数据填充其适配器,并告知适配器其数据集已更改。呼!

这就是 DayFragment 创建和使用 Loader 的方式

public void onStart()
{
    super.onStart();

    getActivity().getSupportLoaderManager().initLoader(0, null, this); // 5-day
    ...
}

在下面的“查询”创建过程中,仅选择所需的字段。KEY_ID 字段是必需的。当点击预报卡时,KEY_FORECAST_ID 字段会传递给 DetailActivity。然后使用此 ID 查询其余字段。其余字段(例如,rainsnowwindhumidity)用于详细活动的显示。

public Loader<cursor> onCreateLoader( int id, @Nullable Bundle args )
{
    CursorLoader loader = null;
    Uri uri = ForecastProvider.DAY_FORECAST_CONTENT_URI;
    String[] strProjection = new String[]{ForecastProvider.KEY_ID,
                                          ForecastProvider.KEY_FORECAST_ID,
                                          ForecastProvider.KEY_FORECAST_DATE,
                                          ForecastProvider.KEY_TEMPERATURE,
                                          ForecastProvider.KEY_FORECAST,
                                          ForecastProvider.KEY_ICON};

    try
    {
        // query the ForecastProvider for all of its elements
        loader = new CursorLoader(getActivity(), uri, strProjection, "", null, null);
    }
    catch (Exception e)
    {
        Log.e(TAG, "  Error creating loader: " + e.getMessage());
    }

    return loader;
}

在创建和查询光标后,我们现在可以将表数据加载到与 RecyclerView 关联的适配器中。对于下面引用的每个字段,该字段必须是上面投影的一部分。适配器使用的数组被清除并用新数据重新加载。一旦所有行都添加到数组中,我们就会告知适配器其数据集已更改,以便 RecyclerView 可以刷新。

public void onLoadFinished( @NonNull Loader<cursor> loader, Cursor data )
{
    try
    {
        arrForecasts.clear();

        if (data.moveToFirst())
        {
            do
            {
                DayForecastDetails dayDetails = new DayForecastDetails();
                dayDetails.m_nForecastId      = 
                   data.getInt(data.getColumnIndex(ForecastProvider.KEY_FORECAST_ID));
                dayDetails.m_lForecastDate    = 
                   data.getLong(data.getColumnIndex(ForecastProvider.KEY_FORECAST_DATE));
                dayDetails.m_strTemp          = 
                   data.getString(data.getColumnIndex(ForecastProvider.KEY_TEMPERATURE));
                dayDetails.m_strForecast      = 
                   data.getString(data.getColumnIndex(ForecastProvider.KEY_FORECAST));
                dayDetails.m_nIcon            = 
                   data.getInt(data.getColumnIndex(ForecastProvider.KEY_ICON));

                arrForecasts.add(dayDetails);
            } while (data.moveToNext());
        }

        m_callback.OnUpdateTitle();

        adapter.notifyDataSetChanged();
    }
    catch(Exception e)
    {
        Log.e(TAG, "  Error reading from 'forecast' cursor: " + e.getMessage());
    }
}

HourFragmentonCreateLoader()onLoadFinished() 方法在功能上是相同的,但选择的字段不同。

关注点

带圆角的标签页

在该项目的后期,我想做一些事情,让这两个标签页“不那么无聊”。我最初的想法是让标签页有圆角。在尝试寻找这方面的想法时,我发现几个网站展示了实现此目的的不同方法,但它们似乎都需要比我想要的更多的代码。我最后找到一个网站,它只是谈论将 TabLayout 包装在一个称为 MaterialCardView 的材质小部件中。这只会产生每个标签页外侧的圆角,而不是标签页两侧的圆角。尽管如此,它还是实现了我想要的效果。在 main_activity.xml 文件中,它最终看起来像

<com.google.android.material.card.MaterialCardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="15dp" >

    <com.google.android.material.tabs.TabLayout
        android:id="@+id/tabs"
        android:layout_width="match_parent"
        android:layout_height="40dp"
        app:tabMaxWidth="0dp"
        app:tabGravity="fill"
        app:tabMode="fixed"
        app:tabTextColor="@android:color/white"
        app:tabBackground="@drawable/tab_background_color"
        android:background="?attr/colorPrimary" />

</com.google.android.material.card.MaterialCardView>

设置 tabMaxWidth="0" 是为了处理这种情况:在纵向模式下两个标签页看起来很好,但在横向模式下它们没有填满屏幕的宽度。

获取位置的时区

虽然与天气预报无关,但我希望显示有关所请求位置的一些基本信息。除了城市、州、邮政编码和纬度/经度坐标外,我还想要该位置的时区。这些免费信息来自 TimeZoneDB。由于信息是 JSON 格式,JSON 文件的下载和解析与从 AccuWeather 检索的位置和预报文件相同。

整个检索过程在辅助线程中完成,以避免中断 UI 线程或可能正在忙于检索预报数据的其他两个辅助线程。

时区 URL 包含 API 密钥和所请求位置的纬度/经度坐标。由于此检索是在已搜索位置或已检索设备位置之后进行的,因此我们知道纬度/经度坐标。因此,URL 将如下所示

https://api.timezonedb.com/v2.1/get-time-zone?key=[API_KEY]&format=json&by=position&lat=35.6207816&lng=-108.8059262

返回的是最基本的 JSON 文件

{
    "status":"OK",
    "message":"",
    "countryCode":"US",
    "countryName":"United States",
    "zoneName":"America\/Denver",
    "abbreviation":"MDT",
    "gmtOffset":-21600,
    "dst":"1",
    "zoneStart":1583658000,
    "zoneEnd":1604217600,
    "nextAbbreviation":"MST",
    "timestamp":1584613637,
    "formatted":"2020-03-19 10:27:17"
}

在这些值中,我们感兴趣的是 abbreviationgmtOffset。检索这些值并将其存储在首选项文件中的代码如下所示

try
{
    JSONObject object = new JSONObject(builder.toString());
    int mOffset = object.getInt("gmtOffset") / 3600;
    String mTZName = object.getString("abbreviation");

    SharedPreferences.Editor editor = mPrefs.edit();
    editor.putInt("TimeZoneOffset", mOffset);
    editor.putString("TimeZoneName", mTZName);
    editor.commit();
}
catch(Exception e)
{
    Log.e(TAG, "  Error parsing TimeZone JSON: " + e.getMessage());
}

时区偏移量和名称稍后用于显示有关所请求位置的信息。

写这类文章的一个好处是,它会促使你以不同于实际编写代码时的视角来看待你的代码。我偶然发现,AccuWeather API 也提供了时区信息,特别是位置服务。虽然我在上面“下载和解析 JSON 文件”一节中对代码做了一个小小的修改,但我在这里包含了时区解析代码,只是为了进行比较。我还保留了项目中的 TimeZone 类。

最终产品

对于这样一个规模的项目,毫无疑问还有许多代码片段可以展示和讨论。我将把这项练习留给感兴趣的读者。

当应用程序首次启动时,还没有选择任何位置,因此会显示一个空视图。RecyclerView 没有 setEmptyView() 方法,因此当任一适配器为空时显示空视图的能力由 EmptyRecyclerView 处理。在这个 RecyclerView 扩展中有一个 AdapterDataObserver 对象,它监视视图底层适配器的变化。

通过工具栏中的搜索我的位置按钮选择要检索预报的位置。对于后者,您将被提示允许应用程序访问设备的。一旦授予权限,检索过程就会开始(涉及上面讨论的所有内容)。现在主活动的两个标签页看起来像

5 天标签页包含未来 5 天(今天加 4 天)的预报数据。每天包含两部分:白天和黑夜。12 小时标签页包含未来 12 小时的预报数据。可以通过垂直滑动手势刷新任一标签页中的数据。

我在本文中多次提到显示位置的基本信息。当点击标题时,会显示一个包含此类信息的对话框,如下所示

当点击其中一个预报卡时,会启动另一个活动来显示这些详细信息。这个详细活动看起来像

对于 Humidity(湿度)、Rain(雨)和 Snow(雪)部分,如果预报中不存在任何这些元素,则这些部分会被隐藏。用于指示风向的罗盘玫瑰的详细信息包含在 Compass 类中。

结语

通常,我会将 APK 文件包含在本文顶部的下载链接中。但是,由于这里使用了 API 密钥,您需要注册才能使用 AccuWeather 和 Google Places API。这是一个一次性的事情,所以一旦您有了 API 密钥,只需将它们放在 Utility 类中的合适位置,重新编译,就可以了。Google Places API 没有使用频率限制,但免费的 AccuWeather API 有:每天 50 个请求。这就是为什么我没有直接保留我的密钥!

尽情享用!

历史

  • 2020年6月12日:初始版本
© . All rights reserved.