使用Volley创建一个火星天气应用

英文原文: Creating a Weather Application for Mars Using Volley

这篇文章是系列文章An Introduction to Volley的一部分,上一篇文章是:An Introduction to Volley

result.jpg

介绍

在这篇文章中,我会演示一个从前一篇文章 中学到的有关Volley的使用案例。我们将创建一个火星天气的应用,使用{MAAS} API公开的探索者号搜集的数据。

首先,我们在Android Studio中配置好项目,并设计好用户界面。然后使用Volley完成应用的核心部分。因为每个漂亮的应用往往都有一些特色图片,所以我将演示如何使用 Flickr的api获取一张随机图片。我们将使用Volley来下载图片,主要是考虑到其强大的缓存系统。最后我们将添加一些漂亮的细节让app看起来 更美观。

  1. 项目设置

首先,在Android Studio中创建一个新的项目。因为Volley是向后兼容的,所以你可以选择任意级别的api。我选择的是API 21,不过只要api在8以上应该都是很ok的。

第一步: 用户界面

我们的应用只有一个简单的Activity。直接使用Android Studio建议的MainActivity.java。打开布局编辑器,双击activity_main.xml。因为屏幕70%的范围都被图片占用,剩下的部分是天气信息,所以我们要使用xml的layout_weight属性。你当然也可以使用绝对值,但是不同的设备值是不同的。如果使用一个绝对的值,在很小的设备上图像可能是90-10的比率,在较大的设备上可能是70-30甚至60-40的比率。而layout_weight属性可以解决这个问题。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
 
    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="0.68"
        android:background="#FF5722">
 
        <!-- image -->
 
    </RelativeLayout>
 
    <RelativeLayout
        android:layout_weight="0.33"
        android:layout_height="0dp"
        android:layout_width="match_parent"
        android:paddingTop="@dimen/activity_horizontal_margin"
        android:background="#212121">
 
        <!-- TextViews -->
 
    </RelativeLayout>
 
</LinearLayout>

在里面的第一个child中,添加ImageView:

<ImageView
   android:id="@+id/main_bg"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:scaleType="centerCrop"/>

在第二个RelativeLayout中,我们添加了几个TextView元素。其中两个分别显示平均温度与天气状态,第三个是显示错误信息的标签。

<TextView
    android:id="@+id/error"
    android:layout_centerInParent="true"
    android:visibility="gone"
    android:layout_height="wrap_content"
    android:layout_width="match_parent"
    android:textSize="20sp"
    android:textColor="#FF5722"
    android:layout_margin="@dimen/activity_horizontal_margin"
    android:gravity="center"
    android:text="I'm sorry.\\nI wasn't able to retrieve real time data."/>
 
<TextView
    android:id="@+id/degrees"
    android:layout_height="wrap_content"
    android:layout_width="wrap_content"
    android:layout_centerHorizontal="true"
    android:textSize="90sp"
    android:textColor="#FF5722"
    android:text="-36°"/>
 
<TextView
    android:id="@+id/weather"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerHorizontal="true"
    android:layout_below="@id/degrees"
    android:textSize="30sp"
    android:gravity="center"
    android:textColor="#FF5722"
    android:text="Sunny"/>

现在布局已经完成了。如果你愿意,可以自己添加更多的详细信息,但是复杂和详细的用户界面并不在本教程的范围之内。

第二步: 主题和权限

在开始深入应用的核心功能之前,还要先处理两个事情。

1.将继承的主题改成android:Theme.Material.Light.NoActionBar,这样我们就不必在运行时隐藏actionbar。

<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar"/>

2.在项目的manifest中添加网络权限。

<uses-permission android:name="android.permission.INTERNET" />
  1. 应用核心

第一步: 导入Volley

正如我在上篇文章中讲述的,使用Volley最简单且可靠的方式是将library作为一个module导入。下载library的 源代码 ,通过File > New > Module将其导入,然后在 build.gradle文件中告诉compiler把它包含在项目中。

compile project(":volley")

第二步: 实现帮助类

上篇文章  我们已经指出了,如果你想发起多个请求,最好使用共享的请求队列,要避免每次布置一个请求都使用Volley.newRequestQueue来创建一个新的队列,因为我们不想看到内存泄漏或者其他的问题发生。

为了达到这个目的,你首先需要创建一个使用了单例模式的类。这个类被作为静态的,全局的对象引用,然后由它处理RequestQueue对象。这样,整个应用中就只有一个RequestQueue。接下来,继承Application类,你需要告诉系统在应用启动的时候创建这个对象,这个过程甚至发生在第一个Activity创建之前。

因为我们是在安卓系统中,所以我们要简单的修改一下常规单例模式的结构。这个类需要在Application.onCreate方法中创建一个自己的实例 - 而不是在常规的getInstance方法中判断它为null的时候产生一个实例。

为此,我们创建一个继承自Application类的MarsWeather.java类,重写onCreate方法,初始化这个静态实例的RequestQueue对象,在这个单例类中,我们使用一个公共的synchronized方法getInstance来构造类的对象。在getInstance方法内部,返回一个mInstance变量。onCreate是在应用启动的时候触发,因此mInstance变量在getInstance方法被调用的第一时间就已经设置好了。

public class MarsWeather extends Application {
 
    private RequestQueue mRequestQueue;
    private static MarsWeather mInstance;
 
    @Override
    public void onCreate() {
        super.onCreate();
        mInstance = this;
        mRequestQueue = Volley.newRequestQueue(getApplicationContext());
    }
 
    public static synchronized MarsWeather getInstance() {
        return mInstance;
    }
     
}

接下来,在AndroidManifest.xml文件中告诉系统你想让MarsWeather在应用启动的时候被加载,在标签下,如下添加属性名称:

android:name=".MarsWeather"

好了,一个Application类的实例就创建好了,它甚至要比MainActivity都要先创建。除了那些标准的操作,比如调用super.onCreate,onCreate还产生了一个RequestQueue的实例。除此之外,我们还需要实现另外三个方法才能完成这个帮助类。

第一个方法是getRequestQueue,替代了Volley.newRequestQueue,直接返回在onCreate中实例化了的mRequestQueue。

我们还需要一个添加请求到队列的方法add,以及负责取消请求的方法cancel。这三个方法实现如下:

public RequestQueue getRequestQueue() {
    return mRequestQueue;
}
 
public <T> void add(Request<T> req) {
    req.setTag(TAG);
    getRequestQueue().add(req);
}
 
public void cancel() {
    mRequestQueue.cancelAll(TAG);
}

TAG是一个用于识别请求的标记。在这个例子中,可以是任意值:

public static final String TAG = MarsWeather.class.getName();

第三步: 实现自定义的Request

正如你已经知道的,Volley提供了三种标准的请求类型:StringRequest, ImageRequest, 和 JsonRequest。我们的应用将使用后面的那个去获取天气数据和随机图片列表。

默认情况下,Volley将请求的优先级设置为普通。一般这也没什么问题,然而在我们的应用中,两种请求的差别很大,有必要让它们在队列中有不同的优先级。获取天气数据的优先级要比获取随机图片的高。

因此我们需要自定义JsonRequest类。创建一个继承自JsonObjectRequest的CustomJsonRequest.java,然后如下重写getPriority方法

public class CustomJsonRequest extends JsonObjectRequest {
 
    public CustomJsonRequest(int method, String url, JSONObject jsonRequest,
                             Response.Listener<JSONObject> listener, Response.ErrorListener errorListener) {
        super(method, url, jsonRequest, listener, errorListener);
    }
 
    private Priority mPriority;
 
    public void setPriority(Priority priority) {
        mPriority = priority;
    }
 
    @Override
    public Priority getPriority() {
        return mPriority == null ? Priority.NORMAL : mPriority;
    }
 
}

第四步: 获取数据

接下来总轮到算到了教程最有趣的部分-获取天气数据,请求的后端地址是:

http://marsweather.ingenology.com/v1/latest/

这个api是可以直接浏览的,那我们打开这个链接查看返回的JSON结果。这个JSON包含了一个简单的数据对象,由一些列的字符串组成,从温度到风向以及日落时间。

我们首先在MainActivity class中声明以下的变量:

TextView mTxtDegrees, mTxtWeather, mTxtError;
MarsWeather helper = MarsWeather.getInstance();
final static string RECENT_API_ENDPOINT = "http://marsweather.ingenology.com/v1/latest/";

你可以在onCreate之外调用MarsWeather.getInstance。既然这个类早就初始化了,你也就不必等到onStart方法执行的时候才调用它。当然界面中的view还是需要在onCreate方法中引用。

mTxtDegrees = (TextView) findViewById(R.id.degrees);
mTxtWeather = (TextView) findViewById(R.id.weather);
mTxtError = (TextView) findViewById(R.id.error);

完成这些工作之后,我们开始实现loadWeatherData方法。我们创建了一个自定义的Volley请求并设置优先级为高。然后我们调用了帮助类的add方法将请求添加进队列中。最重要的其实是result listener部分,因为它将关系到用户界面。

private void loadWeatherData() {
    CustomJsonRequest request = new CustomJsonRequest
        (Request.Method.GET, RECENT_API_ENDPOINT, null, new Response.Listener<JSONObject>() {
 
            @Override
            public void onResponse(JSONObject response) {
                try {
 
                    String minTemp, maxTemp, atmo;
                    int avgTemp;
 
                    response = response.getJSONObject("report");
 
                    minTemp = response.getString("min_temp"); minTemp = minTemp.substring(0, minTemp.indexOf("."));
                    maxTemp = response.getString("max_temp"); maxTemp = maxTemp.substring(0, maxTemp.indexOf("."));
 
                    avgTemp = (Integer.parseInt(minTemp)+Integer.parseInt(maxTemp))/2;
 
                    atmo = response.getString("atmo_opacity");
 
 
                    mTxtDegrees.setText(avgTemp+"°");
                    mTxtWeather.setText(atmo);
 
                } catch (Exception e) {
                    txtError(e);
                }
 
            }
        }, new Response.ErrorListener() {
 
            @Override
            public void onErrorResponse(VolleyError error) {
                txtError(error);
            }
        });
 
    request.setPriority(Request.Priority.HIGH);
    helper.add(request);
 
}

你可以看到,这个方法获取了最低和最高温度,计算了平均温度,然后用它们更新了用户界面。我还实现了一个处理错误的简单方法。

private void txtError(Exception e) {
    mTxtError.setVisibility(View.VISIBLE);
    e.printStackTrace();
}

我们现在只需要在onCreat中调用已经完成的loadWeatherData方法,app现在已经可以显示来自火星上的天气数据了。

  1. 获取图片数据

既然已经完成了app的核心部分,那么我可以考虑如何让app看起来更吸引人一些。可以随机的获取一张火星图片,然后显示给用户。

第一步: 获取一张随机的图片

你需要一个Flickr API key来获取一组随机图片。图片的后端地址是:

https://api.flickr.com/services/rest/?format=json&nojsoncallback=1&
sort=random&method=flickr.photos.search&tags=mars,planet,rover&tag_mode=all&
api_key=\[YOUR_KEY\]

可以看到,这个请求非常简单。你可以告诉Flickr给你一个JSON格式的结果(format=json),但是我们并不会指定一个JSON callback(nojsoncallback=1)。你是要搜索一张图片(method=flickr.photos.search),并且感兴趣的是和火星相关的标签(tags=mars,planet,rover)。可以查阅documentation 以了解关于请求URL格式的更多信息。

声明如下变量:

final static String
    FLICKR_API_KEY = "\[INSERT HERE YOUR API KEY\]",
    IMAGES_API_ENDPOINT = "https://api.flickr.com/services/rest/?format=json&nojsoncallback=1&sort=random&method=flickr.photos.search&" +
            "tags=mars,planet,rover&tag_mode=all&api_key=";

接下来实现searchRandomImage方法:

private void searchRandomImage() throws Exception {
    if (FLICKR_API_KEY.equals(""))
        throw new Exception("You didn't provide a working Flickr API!");
 
    CustomJsonRequest request = new CustomJsonRequest
        (Request.Method.GET, IMAGES_API_ENDPOINT+ FLICKR_API_KEY, null, new Response.Listener<JSONObject>() {
 
            @Override
            public void onResponse(JSONObject response) {
                try {
                    JSONArray images = response.getJSONObject("photos").getJSONArray("photo");
                    int index = new Random().nextInt(images.length());
 
                    JSONObject imageItem = images.getJSONObject(index);
 
                    String imageUrl = "http://farm" + imageItem.getString("farm") +
                            ".static.flickr.com/" + imageItem.getString("server") + "/" +
                            imageItem.getString("id") + "_" + imageItem.getString("secret") + "_" + "c.jpg";
 
                    // TODO: do something with *imageUrl*
 
                } catch (Exception e) { imageError(e); }
            }
        }, new Response.ErrorListener() {
            @Override
            public void onErrorResponse(VolleyError error) {
                imageError(error);
            }
        });
    request.setPriority(Request.Priority.LOW);
    helper.add(request);
}

可以看到Flickr发送回了一个包含图片的JSONArray。在这个方法中我产生了一个在0和数组大小之间的随机数字,以随机的从图片数组中获取一张图片。从数组的索引中获取相应的item然后根据这些指导构造一个图片的URL。

跟前面一样,我们需要一个处理错误的方法:

int mainColor = Color.parseColor("#FF5722");
 
private void imageError(Exception e) {
    mImageView.setBackgroundColor(mainColor);
    e.printStackTrace();
}

最后,在onCreate中调用searchRandomImage,别忘记捕获可能出现的异常。

第二步: 显示图片

我们已经有了待加载的URL,可以开始显示图片了。从前面的文章中,你已经学会了该如何做。

private void loadImg(String imageUrl) {
    // Retrieves an image specified by the URL, and displays it in the UI
    ImageRequest request = new ImageRequest(imageUrl,
            new Response.Listener<Bitmap>() {
                @Override
                public void onResponse(Bitmap bitmap) {
                    mImageView.setImageBitmap(bitmap);
                }
            }, 0, 0, ImageView.ScaleType.CENTER_CROP, Bitmap.Config.ARGB_8888,
            new Response.ErrorListener() {
                public void onErrorResponse(VolleyError error) {
                    imageError(error);
                }
            });
 
    // we don't need to set the priority here;
    // ImageRequest already comes in with
    // priority set to LOW, that is exactly what we need.
    helper.add(request);
}

In the onResponse method we wrote in the previous step, we are finally able to handle the result.

loadImg(imageUrl);

第三步: 每天显示一张新的图片

你可能已经注意到我们在应用每次启动的时候获取一张随机图片,绕过了Volley的缓存机制。需要想办法在一天中只显示同一张图片。最简单的办法就是使用安卓的SharedPreferences,先从声明所需的变量开始:

SharedPreferences mSharedPref; 
 
int today = Calendar.getInstance().get(Calendar.DAY_OF_MONTH);
 
final static String SHARED_PREFS_IMG_KEY = "img",
SHARED_PREFS_DAY_KEY = "day";

接下来,在onCreate方法中,在调用searchRandomImage之前初始化mSharedPref。

mSharedPref = getPreferences(Context.MODE_PRIVATE);

思路是在每次获取一张新的随机图片的时候都保存当天的日期(其实保存的是某月的第几天)。当然,我们还同时保存了该天图片的URL。当应用启动的时候,我们检查在SharedPreferences中是否有当天的入口,如果已经有匹配,我们使用保存了的URL,反之获取一张随机的图片,并在SharedPreferences中保存下URL。

相关的代码是在searchRandomImage方法中,在定义完imageUrl之后,添加如下的代码:

// right after *String imageUrl = .... *
 
// store the pict of the day
SharedPreferences.Editor editor = mSharedPref.edit();
editor.putInt(SHARED_PREFS_DAY_KEY, today);
editor.putString(SHARED_PREFS_IMG_KEY, imageUrl);
editor.commit();
     
// and then there's *loadImage(imageUrl);*

然后在onCreate方法中,mSharedPref定义之后的代码就变成了:

if (mSharedPref.getInt(SHARED_PREFS_DAY_KEY, 0) != today) {
    // search and load a random mars pict
    try {
        searchRandomImage();
    } catch (Exception e) {
        // please remember to set your own Flickr API!
        // otherwise I won't be able to show
        // a random Mars picture
        imageError(e);
    }
} else {
    // we already have a pict of the day: let's load it
    loadImg(mSharedPref.getString(SHARED_PREFS_IMG_KEY, ""));
}
loadWeatherData();

好了,应用已经完成,可以在GitHub上免费下载本教程的完整源码。如果在这篇文章中遇到什么问题可以参考完整的源码。

**Bonus Tip: 改善用户界面
**

第一步: 字体

用户界面中的字体决定了一个应用的直观感受。我们先从改变默认的Roboto字体开始,用更好看的字体来替代,比如 Lato light

assets文件夹下创建一个名叫fonts的文件夹。如果你没找到assets文件夹,你需要在java文件夹的同一级目录下创建一个。文件夹的结构差不多是这样:app\src\main\assets\fonts

Lato-light.ttf拷贝到fonts文件夹中。在onCreate方法中你需要重写那些想使用新字体的View的默认typeface。

mTxtDegrees.setTypeface(Typeface.createFromAsset(getAssets(), "fonts/Lato-light.ttf"));
mTxtWeather.setTypeface(Typeface.createFromAsset(getAssets(), "fonts/Lato-light.ttf"));

第二步: 透明的Status Bar

根据安卓Material Design的规范,我们可以是状态栏透明,这样背景就可以透过状态栏部分可见。

你可以通过应用主题的一点小小改动达到此目的。 如下编辑v21\style.xml

<resources>
    <style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
        <item name="android:windowTranslucentStatus">true</item>
    </style>
</resources>

确保AndroidManifest.xml已经设置成使用这个主题:

<application
        android:name=".MarsWeather"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >

总结

我们完成了一个漫长的旅程。在第一篇文章中,我们讨论了Volley以及应用。这篇教程中,我们通过创建一个火星天气应用来实践了所学的基本知识。你应该对Volley库及其应用范围都有了良好的认识。

源码:GitHub

我整合了volley的Eclipse版本的源码,可直接导入 : http://pan.baidu.com/s/1kT6AbRL ,另外demo中的flickr api没有key,所以无法请求到图片数据,需要你自己申请一个。