精通安卓游戏开发-二-

46 阅读1小时+

精通安卓游戏开发(二)

原文:zh.annas-archive.org/md5/021B82B2841EB90A5AA147BA73C3393A

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:CardView 和材料设计

在本章的第一部分,我们将从 UI 角度显著改进我们的应用程序,使其看起来更专业,从新的小部件:CardView 开始。我们将学习如何使用设计时属性,这将提高我们的设计和开发速度,并且我们将使用第三方库轻松地在整个应用程序中包含自定义字体。

第二部分将重点介绍设计支持库,将材料设计概念添加到我们的应用程序中,改进标签,并在工作机会视图中添加视差效果。在此过程中,我们将阐明工具栏、操作栏和应用程序栏是什么,以及如何从应用程序栏实现上导航。

  • CardView 和 UI 技巧:

    • CardView

    • 设计时布局属性

    • 自定义字体

  • 设计支持库:

    • TabLayout

    • Toolbar、操作栏和应用程序栏

    • CoordinatorLayout

    • 上导航

CardView 和 UI 设计技巧

目前,我们的应用程序以行显示工作机会,其中包含两个文本视图;它展示了所需的信息,我们可以说应用程序目前是好的,并且达到了它的目的。然而,我们仍然可以让应用程序实用,同时拥有专业且美观的界面,使我们能够保持原创并与竞争对手不同。例如,为了展示工作机会,我们可以模拟一个带有广告海报的职位公告板。为此,我们可以使用 CardView 小部件,它将赋予其深度和纸张卡片的外观。我们将改变我们应用程序的字体。这样的简单改变会产生很大的不同;当我们把默认字体改为自定义字体时,从用户的角度来看,这个应用程序就是一个定制的应用程序,开发者已经注意到了最小的细节。

介绍 CardView

CardView 随 Android 5.0 一起发布。它是一个带有圆角和阴影高度的视图,因此具有深度感,并模拟出卡片的效果。将此与回收视图结合使用,我们可以得到一个外观出色的项目列表,其行为和外观与许多应用程序保持一致。以下是使用 CardView 和自定义字体列表的示例图片:

介绍 CardView

在使用 CardView 时,请记住,圆角根据 Android 版本的不同实现方式也不同。在 Android 5.0 之前的版本中,会添加内边距以避免裁剪子视图,同时也是为了实现阴影效果。在 Android 5.0 及以后的版本中,阴影会根据 CardView 的 elevation 属性显示,任何与圆角相交的子视图都会被裁剪。

要开始使用 CardView,我们需要从项目结构窗口将其作为依赖项添加,或者在 build.gradle 的依赖项内部添加以下行:

dependencies {
  ...
  compile 'com.android.support:cardview-v7:21.0.+'
}

我们可以修改 row_job_offer.xml 文件,将基础视图设置为带有内部内容的 CardView。这个 CardView 将具有一些高度和圆角属性。要设置这些属性,我们需要通过在 XML 中添加以下架构来导入 CardView 自有的属性:

下面的代码将创建新的布局:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView

    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="170dp"
    android:layout_margin="10dp"
    card_view:cardElevation="4dp"
    card_view:cardCornerRadius="4dp"
    >
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="wrap_content"
        android:padding="15dp"
        android:layout_height="wrap_content">
        <TextView
            android:id="@+id/rowJobOfferTitle"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:text="Title"
            android:textColor="#555"
            android:textSize="18sp"
            android:layout_marginBottom="20dp"
            />
        <TextView
            android:id="@+id/rowJobOfferDesc"
            android:layout_marginTop="5dp"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:text="Description"
            android:textColor="#999"
            android:textSize="16sp"
            />
    </LinearLayout>
</android.support.v7.widget.CardView>

我们找到了一块软木塞的纹理,将其设置为背景,并在每张卡片上添加了一个带有ImageView对象的图钉。下面是实现的效果:

介绍 CardView

应用看起来比之前好多了;现在它真成了一个职位公告板。仅仅通过展示同样的信息——同样的两个带有标题和职位描述的TextView——并简单地改变外观,它就从演示应用变成了一个完全可以发布在 Play 商店的应用。

我们可以通过更改字体来继续优化这个,但在那之前,我们将介绍设计时布局属性,这将使视图的设计更加简单快捷。

设计时布局属性

在使用设计时属性时,我总会想起我在第一份工作中发生的一个有趣故事。我需要显示联系人列表,所以在创建联系人视图时,我使用了虚拟数据,这在你创建视图时用于分配一些文本,以便在设计视图中可以看到大小、颜色和整体外观。

我创建的联系人名为Paco el churrero,即弗兰克,这位吉事果制作师。Paco 是弗朗西斯科的昵称,而吉事果——如果你不知道——是一种油炸面食。不管怎样,这个虚拟数据被替换成了一个合适的联系人姓名,当显示联系人列表时,这些联系人是从服务器获取的。我不记得是因为急于发布应用,我忘记了这件事,还是我简单地忽略了它,但应用就这样上线了。我开始处理另一个组件,一切都很顺利,直到有一天,服务器端出现了问题,服务器开始发送空联系人。应用无法用联系人姓名覆盖虚拟数据,结果 Paco el churrero 作为联系人显示了出来!幸运的是,在用户注意到之前,服务器得到了修复。

这之后,我创建视图时使用了虚拟数据,一旦我对视图满意了,我就移除了虚拟数据。然而,这种方法是,当有人要求更改 UI 时,我不得不再次添加虚拟数据。

随着 Android Studio 0.2.11 版本的发布,设计时布局属性应运而生。这些属性允许我们在设计视图中显示文本或任何属性,这些在应用运行时不会出现;这些数据只在设计视图中可见。

要使用这些属性,我们需要在布局中添加工具的命名空间。该命名空间总是在视图的根元素中定义;你可以找到这样的一行,informalexample">

<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android 

为了测试这个,我们将在职位信息和职位描述的TextView中添加一些虚拟文本:

<TextView
    android:id="@+id/rowJobOfferTitle"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    tools:text="Title of the job"
    android:textColor="#555"
    android:textSize="18sp"
    android:layout_marginBottom="20dp"
    />
<TextView
    android:id="@+id/rowJobOfferDesc"
    android:layout_marginTop="5dp"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    tools:text="Description of the job"
    android:textColor="#999"
    android:textSize="16sp"
    android:ellipsize="marquee"
    />

如果你遇到设计视图渲染问题,可以更改 Android 版本或主题,如下面的图片所示。如果问题依旧,请确保你已经安装了最新版本的 Android Studio 和下载了最新的 Android API。

设计时的布局属性

当视图渲染后,我们可以看到使用设计时属性的职业提供信息,包括标题和描述。

设计时布局属性

你可以使用任何属性,文本颜色,背景颜色,甚至图片源,这对于创建包含从互联网下载图片的视图非常有用,但你需要一个预览图像来查看在创建视图时的视图外观。

在 Android 中处理自定义字体

在 Android 上使用自定义字体时,有一个令人惊叹的开源库——Chris Jenkins 的Calligraphy——它允许我们为整个应用设置默认字体。这意味着每个带有文本的组件,如 Button、TextView 和 EditText 默认都会显示这种字体,我们不需要为应用中的每个单独的项目分别设置字体。让我们更详细地了解这一点,并考虑一些支持 Calligraphy 的论据。

如果我们想要应用一个自定义字体,我们需要做的第一件事是将该字体放置在应用的assets文件夹中。如果我们没有这个文件夹,我们需要在main目录中创建它,与javasrc同一级别。在assets中创建一个名为fonts的第二个文件夹,并将字体放在那里。在我们的示例中,我们将使用Roboto字体;可以从 Google 字体获取它,地址是www.google.com/fonts#UsePlace:use/Collection:Roboto。下载字体后,应用结构应与以下截图类似:

在 Android 中使用自定义字体

字体放置好之后,我们需要从这个字体创建一个Typeface对象,并将其设置为myTextView

Typeface type = Typeface.createFromAsset(getAssets(),"fonts/Roboto-Regular.ttf"); myTextView.setTypeface(type);

如果我们现在想将同一种字体应用到我们应用中的所有组件,比如标签、标题和职位卡片,我们不得不在应用的不同地方重复相同的代码。除此之外,我们还会遇到性能问题。从资源中创建字体需要访问文件;这是一个昂贵的操作。如果我们改变适配器中职位名称和职位描述的字体,我们应用在滚动时将不再流畅。这就带来了额外的考虑;例如,我们不得不在一个静态类中一次加载字体,并在整个应用中使用它。Calligraphy 为我们处理了所有这些事情。

使用 Calligraphy 的另一个好理由是它允许我们在 XML 中设置字体,这样我们可以在同一个视图中拥有不同的字体,而且无需通过编程方式设置字体。我们只需要在组件中添加fontPath属性,并可选地添加ignore属性以避免 Android Studio 未检测到fontPath的警告:

<TextView     android:text="@string/hello_world"     android:layout_width="wrap_content"     android:layout_height="wrap_content"     
fontPath="fonts/Roboto-Bold.ttf"
tools:ignore="MissingPrefix"/>

既然我们已经解释了 Calligraphy 的优点,我们可以将其应用到我们的应用中。在build.gradle中的依赖项中添加以下行:

compile 'uk.co.chrisjenx:calligraphy:2.1.0'

要应用默认字体,请在MAApplication内的Oncreate()中添加以下代码:

CalligraphyConfig.initDefault(new CalligraphyConfig.Builder().setDefaultFontPath("fonts/Roboto-Regular.ttf").setFontAttrId(R.attr.fontPath).build());

以及以下内容,添加到任何想要显示默认字体的活动中:

@Override protected void attachBaseContext(Context newBase) {super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase)); }

最后,我们可以找到一个我们喜欢的手写字体,并将其设置为卡片标题和描述,输出将类似于以下内容:

在 Android 中使用自定义字体

设计支持库

设计支持库以官方方式引入材料设计组件,并兼容从 Android 2.1 开始的 Android 的所有版本。材料设计是与 Android Lollipop 一起引入的一种新的设计语言。在这个库发布之前,我们观看了使用这些组件的应用程序的视频和示例,但没有官方的方法来使用它。这为应用程序设定了一个基线;因此,要掌握 Android,我们需要掌握材料设计。你可以使用以下行进行编译:

compile 'com.android.support:design:22.2.0'

这个库包括视觉组件作为输入文本,带有浮动文本、浮动动作按钮、TabLayout…等等。然而,材料设计不仅仅是视觉组件;它还涉及到其元素之间的移动和过渡,因此引入了CoordinatorLayout

介绍 TabLayout

TabLayout设计库允许我们使用固定或可滚动的标签,标签可以是文本、图标或自定义视图。正如你在书中第一次遇到这个实例时记得的那样,自定义标签并不是那么容易,而且要从滚动标签改为固定标签,我们需要不同的实现方式。

现在,我们想要改变标签的颜色和设计使其固定;我们首先需要做的是进入activity_main.xml,添加TabLayout,移除之前的PagerTabStrip标签。我们的视图将如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    android:layout_height="fill_parent"
    android:layout_width="fill_parent"
    android:orientation="vertical"
    >
    <android.support.design.widget.TabLayout
        android:id="@+id/tab_layout"
        android:layout_width="match_parent"
        android:layout_height="50dp"/>
    <android.support.v4.view.ViewPager
        android:id="@+id/pager"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
    </android.support.v4.view.ViewPager>
</LinearLayout>

当我们有这个时,我们需要将标签添加到Layout标签中。有两种方法可以做到这一点;一种是通过以下方式手动创建标签并将它们添加:

tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));

第二种方法,也就是我们将要实现的标签方式,是将视图页面设置为TabLayout。我们的MainActivity.java类应该如下所示:

public class MainActivity extends ActionBarActivity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    MyPagerAdapter adapter = new MyPagerAdapter(getSupportFragmentManager());
    ViewPager viewPager = (ViewPager) findViewById(R.id.pager);
    viewPager.setAdapter(adapter);

    TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);

    tabLayout.setupWithViewPager(viewPager);
  }

  @Override
  protected void attachBaseContext(Context newBase) {
    super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase));
  }

}

如果我们没有指定任何颜色,TabLayout会使用主题中的默认颜色,并且标签的位置是固定的。我们新的标签栏将如下所示:

介绍 TabLayout

工具栏、动作栏和应用程序栏

在为我们的应用程序添加动作和动画之前,我们需要澄清工具栏、动作栏、应用程序栏和AppBarLayout的概念,因为这些可能会引起一些混淆。

动作栏和应用程序栏是同一个组件;“应用程序栏”只是动作栏在材料设计中获得的新名字。这是位于我们活动顶部的不透明栏,通常显示应用程序的标题、导航选项,并显示不同的操作。图标的显示与否取决于主题:

工具栏、动作栏和应用程序栏

自从 Android 3.0 开始,默认使用 Holo 主题或其任何后代主题,并且这些主题会显示操作栏。

接下来是下一个概念——工具栏。在 API 21,即 Android Lollipop 中引入,它是对操作栏的泛化,不必固定在活动的顶部。我们可以使用setActionBar()方法指定工具栏是否作为活动的操作栏。这意味着工具栏将根据我们的需求作为操作栏或不起作用。

如果我们创建一个工具栏并将其设置为操作栏,我们必须使用带有.NoActionBar选项的主题,以避免在主题中默认出现的操作栏与我们刚刚转换成操作栏的工具栏重复。

在设计支持库中引入了一个名为AppBarLayout的新元素。它是一个LinearLayout,旨在包含工具栏以基于滚动事件显示动画。我们可以通过app:layout_scrollFlag属性指定子视图在滚动时的行为。它旨在被包含在CoordinatorLayout中,而且该组件也在设计支持库中引入,我们将在下一节中进行描述。

使用 CoordinatorLayout 添加动作。

CoordinatorLayout允许我们向应用程序添加动作,将触摸事件和手势与视图连接起来。例如,我们可以协调滚动动作与视图折叠动画。这些手势或触摸事件由Coordinator.Behaviour类处理,而AppBarLayout已经拥有这个私有类。如果我们想要在自定义视图中使用这个动作,我们将不得不自己创建这个行为。

CoordinatorLayout可以实现在我们应用程序的顶层,因此我们可以将其与应用程序栏或活动或片段内的任何元素结合使用。它也可以作为一个容器,与子视图进行交互。

继续我们的应用程序,当我们点击一张卡片时,将显示一份工作机会的完整视图。这将在一个新活动中展示。该活动将包含一个工具栏,显示工作机会的标题和公司标志。如果描述很长,我们需要向下滚动来阅读;此时,我们希望顶部不再相关的公司标志可以折叠起来。同样,当我们向上滚动时,我们希望它再次展开。为了控制工具栏的折叠,我们将需要CollapsingToolbarLayout

描述将包含在NestedScrollView中,这是来自 Android v4 支持库的滚动视图。使用NestedScrollView的原因是,这个类可以将滚动事件传递给工具栏,而ScrollView则不能。确保compile 'com.android.support:support-v4:22.2.0'是最新版本。

我们将在下一章学习如何下载图片,所以现在我们可以从 drawable 文件夹中放置一个图片来实现 CoordinatorLayout 的功能。在下一章,我们将为每个提供工作的公司加载相应的图片。

我们提供的详情视图 activity_offer_detail.xml 将如下所示:

<android.support.design.widget.CoordinatorLayout 

    android:id="@+id/main_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.support.design.widget.AppBarLayout
        android:id="@+id/appbar"
        android:layout_height="256dp"
        android:layout_width="match_parent">
        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/collapsingtoolbar"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">
            <ImageView
                android:id="@+id/logo"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="centerInside"
                android:src="img/googlelogo"
                app:layout_collapseMode="parallax" />
            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_height="?attr/actionBarSize"
                android:layout_width="match_parent"
                app:layout_collapseMode="pin"/>
        </android.support.design.widget.CollapsingToolbarLayout>
    </android.support.design.widget.AppBarLayout>
    <android.support.v4.widget.NestedScrollView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:paddingLeft="20dp"
        android:paddingRight="20dp"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
            <TextView
                android:id="@+id/rowJobOfferDesc"
                android:layout_width="fill_parent"
                android:layout_height="fill_parent"
                android:text="Long scrollabe text"   
                android:textColor="#999"
                android:textSize="18sp"
                />
    </android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>

如你所见,CollapsingToolbar 布局对滚动标志做出反应,并告诉其子元素如何反应。工具栏将固定在顶部,始终保持可见,app:layout_collapseMode="pin"。然而,标志通过视差效果消失,app:layout_collapseMode="parallax"。不要忘记在 NestedScrollview 属性中添加 app:layout_behavior="@string/appbar_scrolling_view_behavior",并清理项目以内部生成此字符串资源。如果你遇到问题,可以直接设置字符串,"android.support.design.widget.AppBarLayout$ScrollingViewBehavior",这将帮助你定位问题。

当我们点击一个职位提供时,我们需要导航到 OfferDetailActivity,并且需要发送该职位提供的信息。正如你可能从初级水平就知道,要在活动之间发送信息,我们使用意图。在这些意图中,我们可以放置数据或序列化的对象。为了能够发送 JobOffer 类型的对象,我们必须创建一个实现 SerializableJobOffer 类。完成这一步后,我们可以在 JobOffersAdapter 中检测元素的点击,如下所示:

public class MyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener{

  public TextView textViewName;
  public TextView textViewDescription;

  public  MyViewHolder(View v){
    super(v);
    textViewName = (TextView)v.findViewById(R.id.rowJobOfferTitle);
    textViewDescription = (TextView)v.findViewById(R.id.rowJobOfferDesc);
    v.setOnClickListener(this);
    v.setOnLongClickListener(this);
  }

  @Override
  public void onClick(View view) {
    Intent intent = new Intent(view.getContext(), OfferDetailActivity.class);
    JobOffer selectedJobOffer = mOfferList.get(getPosition());
    intent.putExtra("job_title", selectedJobOffer.getTitle());
    intent.putExtra("job_description",selectedJobOffer.getDescription());
    view.getContext().startActivity(intent);
  }

启动活动后,我们需要获取标题并将其设置到工具栏上。首先使用虚拟数据测试,在 NestedScrollView 内的 TextView 描述中添加长文本。我们希望可以滚动以测试动画:

public class OfferDetailActivity extends AppCompatActivity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_offer_detail);

    String job_title = getIntent().getStringExtra("job_title");

    CollapsingToolbarLayout collapsingToolbar =
    (CollapsingToolbarLayout) findViewById(R.id.collapsingtoolbar);
    collapsingToolbar.setTitle(job_title);

  }

}

最后,确保在 values 文件夹中的 styles.xml 文件默认使用没有操作栏的主题:

<resources>

  <!-- Base application theme. -->
  <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <!-- Customize your theme here. -->
</style>

</resources>

现在我们准备测试行为。启动应用并滚动到底部。看看图片如何折叠以及工具栏如何固定在顶部。它将与以下截图相似:

使用 CoordinatorLayout 添加动效

我们遗漏了一个属性,以在动画中实现良好的效果。仅仅折叠图片还不够;我们需要使图片平滑地消失,并由工具栏的背景色替换。

CollapsingToolbarLayout 中添加 contentScrim 属性,这将使图片在折叠时使用主题的主要颜色淡入,这与工具栏当前使用的颜色相同:

<android.support.design.widget.CollapsingToolbarLayout
    android:id="@+id/collapsingtoolbar"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_scrollFlags="scroll|exitUntilCollapsed"
    app:contentScrim="?attr/colorPrimary">

使用此属性,应用在展开和折叠时看起来更好:

使用 CoordinatorLayout 添加动效

我们只需通过更改颜色和为图片添加填充来进一步美化应用;我们可以在 styles.xml 中更改主题的颜色:

<resources>
  <!-- Base application theme. -->
  <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="colorPrimary">#8bc34a</item>
    <item name="colorPrimaryDark">#33691e</item>
    <item name="colorAccent">#FF4081</item>
  </style>
</resources>

AppBarLayout 调整为 190dp 并给 ImageView 添加 50dp paddingLeftpaddingRight 以实现以下效果:

使用 CoordinatorLayout 添加动效

返回导航和向上导航

有两种方法可以返回上一个屏幕。一种称为返回导航,是通过返回按钮执行的导航,该按钮可能是硬件按钮或软件按钮,具体取决于设备。

向上导航是 Android 3.0 中与操作栏一同引入的导航方式;在这里,我们可以通过操作栏中显示的指向左边的箭头来返回到上一个屏幕,如下面的截图所示:

返回导航和向上导航

有时我们需要覆盖返回导航的功能。例如,如果我们有一个自定义的WebView并通过浏览器导航,当我们点击返回时,默认情况下返回按钮将导致我们离开活动;然而,我们想要的是返回浏览器使用的历史记录:

@Override
public void onBackPressed() {
  if (mWebView.canGoBack()) {
    mWebView.goBack();
    return;
  }

  // Otherwise defer to system default behavior.
  super.onBackPressed();
}

除了这些,返回导航是默认实现的,与向上导航不同。要实现向上导航,我们需要一个操作栏(或者作为操作栏的工具栏),并且需要通过setDisplayHomeAsUpEnabled(true)方法激活此导航。在我们的活动中的onCreate里,我们将添加以下几行代码,以将我们的工具栏设置为操作栏并激活向上导航:

final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);

这将在我们活动的顶部显示返回箭头,如下面的截图所示。但目前,我们还没有任何功能:

返回导航和向上导航

一旦激活,我们需要捕获操作栏中返回箭头的点击。这将被检测为菜单中带有android.R.id.home ID 的动作选择;我们只需在我们的活动中添加以下代码即可:

@Override
public boolean onOptionsItemSelected(MenuItem item) {
  switch (item.getItemId()) {
    case android.R.id.home:
    finish();
    return true;
  }
  return super.onOptionsItemSelected(item);
}

概述

在本章中,我们的应用程序发生了巨大变化;我们完全改变了工作机会列表,现在它看起来类似于一张张手写的纸张卡片,钉在软木板上。同时,你学习了来自材料设计的概念以及如何使用应用栏和工具栏。设计支持库中还有更多小部件,如InputTextFloatingButton,它们非常容易实现。只需将小部件添加到视图中,这就是为什么我们专注于更复杂的组件,如CoordinatorLayoutCollapsingToolbarLayout

在下一章中,我们将学习如何下载公司标志,直接从 URL 宣传工作,讨论内存管理,并查看如何确保我们的应用中没有内存泄漏。

第七章:图像处理与内存管理

在本章中,我们将探讨如何显示从 URL 下载的图片。我们将讨论如何使用 Android 原生 SDK 以及常用的第三方库来实现这一点。我们会考虑关键概念和特性,如下载、压缩、缓存系统以及在内存或磁盘上的存储。

我们还将讨论什么是九宫格图片以及如何创建它,并且会通过介绍矢量图像,讲解不同尺寸和密度文件夹中的可绘制资源。

最后一节将重点关注内存管理。识别我们应用中的内存泄漏是一项关键任务,这通常发生在处理图像时。我们将探讨可能导致这些泄漏的常见错误以及如何预防的一般性技巧。

  • 显示网络图像

    • 传统方式

    • Volley ImageDownloader

    • Picasso 库

  • 图像

    • 矢量图像

    • 动画矢量图像

    • 九宫格图片

  • 内存管理

    • 检测和定位内存泄漏
  • 防止内存泄漏

下载图片

使用ImageView下载并显示图片可以在一行代码中完成。自从 Android 开发起步,这是每个开发者都会做的事情。Android 是一项超过五年历史的技术,因此我们可以预期这项技术相当先进,也能找到简化它的第三方解决方案。话虽如此,如果这本书不解释在没有任何第三方库的情况下下载和显示图片的过程,它就不会被称为《精通 Android》。

在你的应用中使用最新库是好的,但理解你所实施的解决方案更好,能够自己构建这个库则是最好不过了。

在处理图像时,我们需要从网络连接到数组字节的下载及其转换为位图的一切。在某些情况下,将图像存储在磁盘上有意义,这样下次我们打开应用时,这些图像就已经在那里了。

即使我们能够显示一张图片,事情并没有就此结束;我们应该能够在列表视图中管理图片的下载。下载、存储和显示系统的同步是应用无故障运行、拥有流畅列表且能无问题滚动的关键。请记住,当我们滚动列表时,视图是被回收的。这意味着如果我们快速滚动,可能会开始下载一张图片。等到下载完成时,该视图可能已经不再屏幕上显示,或者它可能被回收用于另一个视图。

下载图片的传统方式

要在不使用任何第三方库(互联网上托管的带有 URL 的图片)的情况下显示图片,我们需要使用HttpURLConnection建立连接。我们需要打开一个输入流并消费信息,这可以通过工厂方法BitmapFactory.decodeStream(InputStream istream)转换为 Bitmap 图像。我们可以将其从输入流转换为文件,以便将图片存储在磁盘上,以后再访问。目前,让我们先尝试下载它并将其转换为 Bitmap 图像,我们将把它保存在内存中并在ImageView中显示。

我们将在OfferDetailActivity中为每个职位展示公司的标志。请记住,在 Parse 中,我们创建了一个数据库,并创建了一个名为imageLink的字段。你只需要用该公司的标志 URL 填充该字段。

传统下载图片的方式

我们需要在OfferDetailActivity中添加图片链接;为此,在JobOfferAdapter中点击卡片时,我们需要在意图中发送一个额外的参数。使用以下代码:

@Override
public void onClick(View view) {
  Intent intent = new Intent(view.getContext(), OfferDetailActivity.class);
  JobOffer offer = mOfferList.get(getPosition());
  intent.putExtra("job_title", offer.getTitle());
  intent.putExtra("job_description",offer.getDescription());
  intent.putExtra("job_image",offer.getImageLink());
  view.getContext().startActivity(intent);
}

负责图片下载的方法将是一个静态方法,可以从应用程序的任何位置调用。这个方法将被放在名为utils的包内的ImageUtils类中。我们首先检查 URL 是否正确,然后从HttpURLConnection消费内容,将输入流转换为之前解释的 Bitmap 图像:

public static Bitmap getImage(String urlString) {

  URL url = null;

  try {
    url = new URL(urlString);
  } catch (MalformedURLException e) {
    return null;
  }

  HttpURLConnection connection = null;
  try {
    connection = (HttpURLConnection) url.openConnection();
    connection.connect();
    int responseCode = connection.getResponseCode();
    if (responseCode == 200) {
      return BitmapFactory.decodeStream(connection.getInputStream());
    } else
      return null;
  } catch (Exception e) {
    return null;
  } finally {
    if (connection != null) {
      connection.disconnect();
    }
  }
}

我们将创建一个名为displayImageFromUrl()的方法,该方法接收ImageView和一个带有链接的字符串,以代替在onCreate中拥有所有这些逻辑。在onCreate中,我们只需要检索参数并调用该方法:

String imageLink = getIntent().getStringExtra("job_image");
ImageView imageViewLogo = (ImageView) findViewById(R.id.logo);

displayImageFromUrl(imageViewLogo,imageLink);

在这个阶段,我们可能会想要调用ImageUtils.getImage(link)并将 Bitmap 设置到ImageView。然而,我们忽略了一件事;我们不能在主活动线程中直接调用打开网络连接的方法。我们需要在后台进行,否则可能会引发异常。AsyncTask方法是这个问题的不错解决方案:

String imageLink = getIntent().getStringExtra("job_image");
ImageView imageViewLogo = (ImageView) findViewById(R.id.logo);

displayImageFromUrl(imageViewLogo,imageLink);

public void displayImageFromUrl(ImageView imageView, String link){

  new AsyncTask<Object,Void,Bitmap>(){

    ImageView imageView;
    String link;

    @Override
    protected Bitmap doInBackground(Object... params) {
      imageView = (ImageView) params[0];
      link = (String) params[1];

      return ImageUtils.getImage(link);
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
      super.onPostExecute(bitmap);
      imageView.setImageBitmap(bitmap);
    }

  }.execute(imageView, link);
}

根据所使用的图片的形状和背景,使用ImageView的属性scaleType,设置为centerInsidecenterCrop值会更好看。centerInside值会将图片缩小以确保它适应接收器同时保持比例。centerCrop值将放大图片,直到它填满接收器最短的一边。图片的其余部分将超出ImageView的边界。

在本章开头,我提到这可以用一行代码就完成,但正如你所见,自己动手做远不止一行代码,还涉及不同的概念,比如后台线程、HttpURLConnection等。这仅仅是开始;我们实现了最简单的情况。如果我们以同样的方式在列表视图的行中设置图像,会遇到问题。这些问题之一就是在滚动时无限触发AsyncTask调用。如果我们有一个带有最大AsyncTask数量的队列以及一个取消机制,以忽略或取消不在屏幕上的视图的请求,这种情况是可以控制的。

当我们启动AsyncTask时,我们有一个指向ImageView的引用,在PostExecute中,我们将Bitmap设置给它。这个下载操作可能需要一些时间,这样在滚动时ImageView可能会被回收。这意味着我们正在为在列表不同位置回收的ImageView下载图像,以显示不同的元素。例如,如果我们有一个带有联系人面孔的列表,我们可能会看到与名字不符的人脸。为了解决这个问题,我们可以做的是将图像链接的字符串设置为ImageView的标签,myImageView.setTag(link)。如果视图被回收,它将具有带有新链接的不同项目;因此,我们可以在onPostExecute中检查,在显示图像之前,当前的链接是否与ImageView标签中的链接相同。

这两个是常见问题及其相应的解决方案,但我们还没有就此结束。如果继续这样下去,最繁琐的事情就是创建一个缓存系统。根据应用和情况的不同,我们可能希望永久存储下载的图像。例如,如果我们正在创建一个带有你最喜欢的专辑列表的音乐应用,将专辑封面存储在磁盘上是有意义的。如果你每次打开应用都会看到最喜欢的列表,并且我们知道封面不会改变,为什么不永久存储图像,以便下次打开应用时加载更快,不消耗任何数据呢?对于用户来说,这意味着每次都能立即看到首屏加载,这将大大提升用户体验。为此,我们需要将图像下载到文件中,并有一个第三种方法稍后从文件中读取图像,包括检查我们是否已经下载了此图像,或者这是我们第一次请求它。

另一个例子可以是新闻源阅读应用。我们知道图片几乎每天都会变化,所以将其保存在磁盘上没有意义。然而,在应用中导航时,我们可能仍然希望将它们保留在内存中,这样在从另一个活动回到当前活动时,在同一个会话中不需要重新下载。在这种情况下,我们需要密切关注内存使用情况。

是时候引入一些第三方库来帮助我们处理这个问题了。我们可以从 Volley 开始,就是我们之前为网络请求实现的那个 Volley。

使用 Volley 下载图片

Volley 提供了两种请求图片的机制。第一种机制ImageRequest与我们刚才使用 Volley 请求队列和按需调整图片大小完成的AsyncTask非常相似。以下是请求的构造函数:

public ImageRequest(String url, Response.Listener<Bitmap> listener, int maxWidth, int maxHeight, Config decodeConfig, Response.ErrorListener errorListener) { … }

maxWidthmaxHeight参数将用于调整图片大小;如果我们不想调整大小,可以将其值设置为0。这是我们示例中用于获取图片的方法:

public void displayImageWithVolley(final ImageView imageView, String url){

  ImageRequest request = new ImageRequest(url,
  new Response.Listener<Bitmap>() {
    @Override
    public void onResponse(Bitmap bitmap) {
      imageView.setImageBitmap(bitmap);
    }
  }, 0, 0, null,
  new Response.ErrorListener() {
    public void onErrorResponse(VolleyError error) {

    }
  });

  MAApplication.getInstance().getRequestQueue().add(request);
}

第二个机制,真正有趣的是ImageLoader。它可以同时处理多个请求,并且是我们之前解释的原因在列表视图中使用的机制。我们可以创建我们希望它使用的缓存机制——内存或磁盘。

它通过使用一种特殊的ImageViewNetworkImageView来工作。当ImageLoader对象准备就绪时,我们可以使用NetworkImageView仅用一行代码下载图片:

myNetworkImageView.setImage(urlString, imageloader);

它允许我们执行不同的操作,例如设置默认图片或设置请求失败时的图片。使用以下代码:

myNetworkImageView.sesetDefaultImageResId(R.id.default_image);
myNetworkImageView.setErroImageResId(R.id.image_not_found);

这里的复杂性,如果有的话,在我们实现ImageLoader时出现。首先,我们需要以在Application类中创建RequestQueue的相同方式创建它,以便可以在我们应用中的任何位置访问:

@Override
public void onCreate() {
  super.onCreate();

  sInstance = this;

  mRequestQueue = Volley.newRequestQueue(this);

  mImageLoader = new ImageLoader(mRequestQueue, new myImageCache());

构造函数需要一个缓存实现。Google 是基于内存缓存的示例,其大小等于三个屏幕的图片量:

public class LruBitmapCache extends LruCache<String, Bitmap>
implements ImageCache {

  public LruBitmapCache(int maxSize) {
    super(maxSize);
  }

  public LruBitmapCache(Context ctx) {
    this(getCacheSize(ctx));
  }

  @Override
  protected int sizeOf(String key, Bitmap value) {
    return value.getRowBytes() * value.getHeight();
  }

  @Override
  public Bitmap getBitmap(String url) {
    return get(url);
  }

  @Override
  public void putBitmap(String url, Bitmap bitmap) {
    put(url, bitmap);
  }

  // Returns a cache size equal to approximately three screens worth of images.
  public static int getCacheSize(Context ctx) {
    final DisplayMetrics displayMetrics = ctx.getResources().
    getDisplayMetrics();
    final int screenWidth = displayMetrics.widthPixels;
    final int screenHeight = displayMetrics.heightPixels;
    // 4 bytes per pixel
    final int screenBytes = screenWidth * screenHeight * 4;

    return screenBytes * 3;
  }
}

我们可以看到,选择缓存实现是一个手动过程;我们必须创建具有所需实现的类,并在ImageLoader的构造函数中设置它。这就是为什么接下来我们要了解的库在推出时是一次革命。

引入 Picasso

创建OkHttp的同一批人将 Picasso 引入了 Android 社区。Picasso 允许我们仅用一行代码下载并显示图片,无需创建ImageLoader,并自动使用磁盘和内存的缓存实现。它包括图像转换,ImageView回收和请求取消。所有这些都是免费的。Square 公司带给社区的东西令人难以置信。

如果这还不够,调试模式将在图片中显示指示器,角落里的小三角形有不同的颜色,以表示我们第一次下载图片时(即从网络获取时),从内存缓存中获取,以及从磁盘缓存中获取:

引入 Picasso

掌握图片处理

在结束关于图片这一章节之前,本书必须介绍两个概念。正如你所知,图片可以根据屏幕密度放在多个文件夹中——从低密度drawable-ldpi到高密度drawable-hdpi,超超高密度drawable-xxxhdpi,将来可能还有更多。当我们这样做时,需要考虑是希望所有屏幕都有高质量的图片,还是一个轻量级的 APK。复制图片将增加我们安装程序的大小。这个问题在 Android 5.0 引入的以下组件中将会消失。

矢量可绘制资源

这些可绘制资源基于矢量图形;矢量图形可以放大或缩小而不损失任何质量。有了这个特点,我们只需要一个单一的可绘制资源,无论在哪种屏幕上使用,它都能保持优良的品质,无论是安卓手表还是安卓电视。

矢量可绘制资源的定义方式与形状的定义方式相同——在 XML 文件中。这是一个简单的vectordrawable.xml文件:

<vector  android:height="64dp" android:width="64dp" android:viewportHeight="600" android:viewportWidth="600">
  <group>
    <path android:fillColor="@color/black_primary" android:pathData="M12 36l17-12-17-12v24zm20-24v24h4V12h-4z" />
  </group>
</vector>

注意矢量标签有高度和宽度;如果我们把这个可绘制资源设置在ImageView中,且大小小于容器,它看起来会像素化。

你可能会问,我们从哪里获取pathData属性?你可能有一个.svg图像,这是可扩展图形的一种格式。这个图像可以用文本编辑器打开,你应该能看到类似以下路径数据的内容:

<svg  width="48" height="48" viewBox="0 0 48 48">
  <path d="M12 36l17-12-17-12v24zm20-24v24h4V12h-4z"/>
</svg>

谷歌提供了一系列的材料设计图标,这些图标包含 SVG 版本;有了这个,你可以在应用中添加无限可扩展的图像。我们展示的路径是这组图标中的媒体播放器图标。

矢量可绘制资源

矢量可绘制资源将被添加到设计支持库中,因此它也可以在 Android 5.0 之前的版本中使用。

下一个组件可能不包括在设计支持库中,因此我们需要考虑是否要使用它,这取决于 Android 5.0 及以上版本的普及程度。无论如何,它值得解释,因为迟早它会因为其惊人的效果而被更广泛地看到。

使用AnimatedVectorDrawable进行动画制作

顾名思义,AnimatedVectorDrawable是一个带有动画的矢量可绘制资源,这是一个重要的特性。这些动画不仅仅是旋转、缩放、透明度等,这些都是我们在之前的 Android 版本中见过的;这些动画还允许我们改变可绘制资源的pathData属性。这意味着我们可以有一个改变形状的图像,或者一个转换成另一个图像的图像。

这带来了无限多的 UI 可能性。例如,我们可以有一个播放按钮变成一个不断旋转的半圆进度条,或者一个播放按钮变成暂停按钮。

我们可以如下定义传统动画,比如旋转:

<objectAnimator

  android:duration="6000"
  android:propertyName="rotation"
  android:valueFrom="0"
  android:valueTo="360" />

下面是如何定义从三角形到矩形的形状转换:

<set
  >
  <objectAnimator
    android:duration="3000"
    android:propertyName="pathData"
    android:valueFrom="M300,70 l 0,-70 70,70 0,0   -70,70z"
      android:valueTo="M300,70 l 0,-70 70,0  0,140 -70,0 z"
    android:valueType="pathType"/>
</set>

要将它们组合在一个 AnimatedVectorDrawable 对象中,执行以下代码:

<animated-vector

  android:drawable="@drawable/vectordrawable" >
  <target
    android:name="rotationGroup"
    android:animation="@anim/rotation" />
  <target
    android:name="v"
    android:animation="@anim/path_morph" />
</animated-vector>

这仅限于具有相同长度和相同命令长度的路径。

使用九宫格

在解释九宫格是什么之前,我将先展示在什么情况下需要它。如果我们正在开发一个消息应用,并且需要在聊天气泡中显示用户输入的内容,我们可以考虑创建一个 TextView 并将消息气泡的图像设置为背景。如果消息非常长,下面分别展示了没有九宫格背景和有九宫格背景的情况。

使用九宫格

我们可以看到第一张图像被拉伸了,看起来很糟糕;然而,我们不想拉伸边界。我们想要保持边界不变,但根据消息内容使文本区域变高或变宽。

九宫格图像是一种可以根据其内容调整大小的图像,但它涉及到留下一些区域不进行拉伸。它可以从 PNG 文件中的图像创建。基本上,它和 PNG 文件一样,只是在每个边上多了一个像素,并以 .9.png 的扩展名保存。当我们把这个文件放在 drawable 文件夹中时,Android 会知道在额外的像素中有关哪些区域需要拉伸和不需要拉伸的信息。

如果你观察这幅图像,你会看到左侧和顶部额外的像素线用于指定哪些内容是可缩放的,底部和右侧的线条用于指定哪些空间可以被填充。我们希望完全填充这个盒子,但我们只想对左侧的某部分进行缩放。

使用九宫格

Android 提供了一个工具来创建这些九宫格图像,你可以在 SDK 文件夹下的 tools 目录中找到它。只需打开 draw9patch 并将图像拖入其中。

使用九宫格

内存管理

每个 Java 开发者都听说过 垃圾回收器 (GC);这是一个自动为我们释放内存资源的机制。在某些情况下,我们可以防止垃圾回收器释放某些资源;如果资源持续增长,我们不可避免地会遇到 OutOfMemoryError

如果发生这种情况,我们需要定位泄漏并阻止它。在本节中,我们将了解如何定位问题的来源以及一系列防止这种情况发生的好习惯。

这不是只有在发生错误时才需要关注的事情;我们的应用程序可能存在内存泄漏,这些泄漏在快速测试中可能不足以被检测出来,但在内存堆较小的设备上可能会导致错误。因此,在发布应用程序之前,对内存水平进行快速检查是很有必要的。

检测和定位内存泄漏

Android Studio 提供了一种快速检查内存状态的方法。在底部窗口中,你会在 logcatADB 日志旁边找到一个名为 Memory 的标签页。

检测和定位内存泄漏

如果你点击了被称为垃圾收集器的小卡车图标,你会看到可用内存增加了。

不要将此作为可用内存的参考,因为堆内存是动态的。这意味着堆内存最初可能是 64 MB,我们有 60 MB 已分配和 4 MB 空闲,但我们再分配 10 MB。堆内存可能会增长,最终我们会有一个 128 MB 的堆内存,其中 70MB 已分配和 58 MB 空闲。

要检测泄漏,我们需要获取分配的内存引用。不断点击垃圾收集器,并在应用中导航,打开和关闭活动,加载图片,滚动列表,多次执行这些操作。如果分配的内存持续增长并且从未下降,这意味着我们在泄漏内存,阻止了一些资源被回收。我们可以大致定位泄漏发生在哪个活动或片段,因为我们将始终在相同点看到增长(假设不止一个泄漏)。

为了更精确地定位问题源头,我们需要使用Android 设备监控器

检测和定位泄漏

选择你的应用进程,并点击更新堆内存

检测和定位泄漏

选择此项后,我们可以看到对象的分配情况;在位图或线程泄漏的情况下,这将是一个很好的线索:

检测和定位泄漏

如果我们仍然不清楚是什么在泄漏内存,可以点击导出 HPROF 文件按钮,并使用来自 Eclipse 的内存分析工具MAT打开此文件。为此,我们将需要下载 Eclipse。

当我们导入文件时,可以双击我们的进程并点击列出对象,这将识别正在发生的情况。例如,我们可以看到活动中有多少对象以及使用了多少堆内存:

检测和定位泄漏

防止泄漏

比起修复内存泄漏,更好的办法是根本不让它发生。如果在开发过程中,我们牢记导致泄漏最常见的原因,这将为我们将来省去许多问题。

活动和上下文引用

活动引用是导致此问题的主要原因之一。我们经常将活动的引用发送给下载监听器或事件监听器,这是很常见的。如果另一个对象持有了我们活动的引用,这将阻止垃圾收集器释放我们的活动。例如,如果我们改变了屏幕方向,默认情况下将重新创建我们的活动,而具有旧方向的老活动将被销毁。

记得在活动的onDestroy方法中取消监听器的订阅,并关注你发送上下文的对象;这是我们活动的强引用。

使用 WeakReference(弱引用)

在 Java 中,当我们默认创建一个对象时,它是以强引用的形式创建的。非 null 对象若持有强引用,则不会被垃圾回收器回收。

只包含弱引用的对象将在下一个周期被垃圾回收。同一个对象可以有多个引用;因此,如果我们需要临时使用一个对象,可以为其创建弱引用,当硬引用被移除时,它将被垃圾回收。

这是一个包含在 Facebook SDK 源代码中的真实世界示例。他们创建了一个名为ToolTipPopup的自定义弹出窗口,其外观类似于以下图像:

使用弱引用

这个弹出窗口需要一个锚视图,这个锚视图是通过弱引用来引用的:

private final WeakReference<View> mAnchorViewRef;

这背后的原因是,在弹出窗口显示的时候,我们不再需要锚视图了。一旦弹出窗口显示,锚视图可以设置为 null 或使其消失,这不会影响我们。因此,使用弱引用,如果原始锚视图被销毁并失去了其硬引用,它也会在ToolTipPopup类中释放弱引用对象。

总结

在本章中,你学习了如何在不依赖任何第三方库的情况下下载图像,以理解它们的用法。对 Volley 和 Picasso 的概述使我们准备好实现任何具有完美处理的应用程序。我们还花了一些时间研究添加到我们应用程序中的图像,如矢量可绘制图像和九宫格图像。为了完成本章,我们探讨了如何在应用程序中管理内存问题,更重要的是,如何预防这些问题。

在下一章中,我们将创建一个 SQLite 数据库。我们将通过内容提供者导出此数据库,并通过CursorLoader与内容提供者同步 UI 数据。

第八章:数据库和加载器

在本章中,我们将根据数据库契约创建一个 SQLite 数据库,并使用名为DAO数据访问对象)的数据库执行读写操作。我们还将解释查询与原始查询之间的区别。

你将了解内容提供者是什么以及如何创建它,这将使得我们可以通过CursorLoader访问这个数据库。我们将通过内容解析器访问内容提供者,并同时对数据库的不同表格进行查询,你将学会如何在内容提供者中使用联合查询。

使用CursorLoader,我们将能够通过创建一个机制,实现列表视图与数据库的同步,如果我们存储或修改数据库中的任何数据,这些变化将自动反映在我们的视图中。

最后,我们将添加流行的下拉刷新功能,以便按需更新内容。因此,在本章中,我们将涵盖以下主题:

  • 创建数据库

    • 数据库契约

    • 数据库开放助手

    • 数据库访问对象

  • 创建和访问内容提供者

    • 内容提供者

    • 内容解析器

  • 同步数据库与 UI

    • CursorLoader

    • RecyclerViewCursorAdapter

  • 下拉刷新

创建数据库

为了了解 Android 中数据库的工作原理,我们将继续在我们的示例应用MasteringAndroidApp上工作,创建一个数据库来存储职位信息,以便在离线模式下查看内容。这意味着如果我们打开应用一次,职位信息将保存在设备上,即使在没有互联网连接的情况下打开,也能查看信息。

在 Android 中有四种机制可以持久化数据:

  • 共享偏好设置:这些偏好设置用于以键值结构存储基本信息

  • 内部存储:这种存储用于保存仅属于你应用私有的文件

  • 外部存储:这种存储用于保存可以与其他应用共享的文件

  • SQLite 数据库:这个基于流行的 SQL 的数据库,允许我们以结构化的方式编写和读取信息

我们可以创建简单的结构,如单表数据库,也可以创建包含多个表格的复杂结构。我们可以合并不同表格的输出以创建复杂的查询。

我们将创建两个表格,以展示如何使用内容提供者创建联合查询。

我们将为公司创建一个表格,其中包括公司 ID、公司相关信息、名称、网站、额外信息等。第二个表格将包含职位信息;这个表格也需要包含一个列,用以存放公司的 ID。如果我们想要一个整洁的结构,而不是一个拥有众多字段的大型表格,最好是将公司信息放在公司表格中,将职位信息放在职位表格中,并通过一个引用关联公司。

为了清晰起见,并且专注于 SQLite,我们不会改变 Parse 中的数据结构。因此,我们将下载内容并手动分割公司和职位数据,将它们插入到不同的表中。

我们的公司表格将具有以下结构:

RowId名称图片链接
0Yahoo….
1Google

rowId列是 Android 自动添加的,因此在创建表时我们不需要指定这个列。

下表是工作机会表:

RowId职位描述薪水地点类型Company_id
24高级安卓开发..2 倍开发者55,000英国伦敦全职1
25初级安卓开发..有经验的开发者20,000英国伦敦全职0

我们将创建一个视图,作为这两个表连接的结果;在这里,连接将基于company_id

职位描述薪水地点类型公司 ID名称图片链接
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
高级安卓开发2 倍开发者..55,000英国伦敦全职1Google
初级安卓开发有经验的开发者20,000英国伦敦全职0Yahoo

这个视图将允许我们获取所需的所有数据,只需一行。

数据库合同

数据库合同是一个类,我们在其中定义了数据库的名称以及所有表和列的名称作为常量。

它有两个目的:首先,它是一种快速了解数据库结构的方法。

要创建数据库包和DatabaseContract.java类,请使用以下代码:

public class DatabaseContract {

  public static final String DB_NAME = "mastering_android_app.db";

  public abstract class JobOfferTable {

    public static final String TABLE_NAME = "job_offer_table";

    public static final String TITLE = "title";
    public static final String DESC = "description";
    public static final String TYPE = "type";
    public static final String SALARY = "salary";
    public static final String LOCATION = "location";
    public static final String COMPANY_ID = "company_id";
  }

  public abstract class CompanyTable {

    public static final String TABLE_NAME = "company_table";

    public static final String NAME = "name";
    public static final String IMAGE_LINK = "image_link";
  }
}

其次,使用对常量的引用避免错误,并允许我们只更改一个常量值,并在整个应用中传播这个更改。

例如,在数据库中创建此表时,我们需要使用 SQL 语句,CREATE TABLE "name"…; 我们将要做的是使用与合同中的表名 CREATE TABLE DatabaseContract.CompanyTable.TABLE_NAME…

数据库合同只是第一步。它不会创建数据库;它只是一个我们用作架构的文件。要创建数据库,我们需要SQLiteOpenHelper的帮助。

数据库开放助手

开放助手是一个管理数据库创建和更新的类。更新是我们需要牢记的重要方面。假设我们将应用上传到 Play 商店,一段时间后,我们想要改变数据库的结构。例如,我们想要向表中添加一列,而不丢失之前版本用户在旧架构中存储的数据。将新版本上传到 Play 商店,当用户更新我们的应用时删除之前的资料,这对用户体验来说是非常不好的。

为了知道何时需要更新数据库,我们有一个静态整数,其中包含数据库版本,如果我们更改数据库,必须手动增加它,如下所示:

/**
* DATABASE VERSION
*/
private static final int DATABASE_VERSION = 1;

我们需要创建一个扩展SQLiteOpenHelperDatabaseOpenHelper类。在扩展这个类时,要求我们实现两个方法:

@Override
public void onCreate(SQLiteDatabase db) {
  //Create database here
}

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
  //Update database here
}

当我们创建此类的一个对象时,SQLiteOpenHelper会自动调用onCreate。但是,它仅在数据库之前未创建且仅调用一次时这样做。同样,当我们将数据库版本升级时,它会调用onUpgrade。这就是为什么我们在创建此类对象时需要传递带有数据库名称和当前版本的参数:

public DBOpenHelper(Context context){
  super(context, DatabaseContract.DB_NAME, null, DATABASE_VERSION);
}

让我们从创建数据库开始;onCreate方法需要在数据库上执行一个 SQL 语句来创建表:

db.execSQL(CREATE_JOB_OFFER_TABLE);
db.execSQL(CREATE_COMPANY_TABLE);

我们将在静态变量中定义这些语句,如下所示:

/**
* SQL CREATE TABLE JOB OFFER sentence
*/
private static final String CREATE_JOB_OFFER_TABLE = "CREATE TABLE "
+ DatabaseContract.JobOfferTable.TABLE_NAME + " ("
+ DatabaseContract.JobOfferTable.TITLE + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.DESC + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.TYPE + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.SALARY + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.LOCATION + TEXT_TYPE + COMMA
+ DatabaseContract.JobOfferTable.COMPANY_ID + INTEGER_TYPE + " )";

默认情况下,Android 在每一行中创建一个column_id列,该列是唯一的且自动递增的;因此,在 companies 表中我们不需要创建列 ID。

如您所见,我们在变量中也有逗号和类型以避免错误。直接编写句子时遗漏逗号或犯错误是非常常见的,而且找出错误非常耗时:

/**
* TABLE STRINGS
*/
private static final String TEXT_TYPE = " TEXT";
private static final String INTEGER_TYPE = " INTEGER";
private static final String COMMA = ", ";

我们已经看到了如何创建我们的表,现在我们必须管理更新。在这种情况下,我们将简单地删除以前的信息并重新创建数据库,因为表中没有重要的信息。更新后打开应用程序时,它将重新下载工作机会并填充新数据库:

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
  db.execSQL(DROP_JOB_OFFER_TABLE);
  db.execSQL(DROP_COMPANY_TABLE);
  onCreate(db);
}

/**
* SQL DELETE TABLE SENTENCES
*/
public static final String DROP_JOB_OFFER_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.JobOfferTable.TABLE_NAME;
public static final String DROP_COMAPNY_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.CompanyTable.TABLE_NAME;

我们类的完整版本将如下所示:

public class DBOpenHelper extends SQLiteOpenHelper {

  private static final int DATABASE_VERSION = 1;

  /**
  * TABLE STRINGS
  */
  private static final String TEXT_TYPE = " TEXT";
  private static final String INTEGER_TYPE = " INTEGER";
  private static final String COMMA = ", ";

  /**
  * SQL CREATE TABLE sentences
  */
  private static final String CREATE_JOB_OFFER_TABLE = "CREATE TABLE "
  + DatabaseContract.JobOfferTable.TABLE_NAME + " ("
  + DatabaseContract.JobOfferTable.TITLE + TEXT_TYPE + COMMA
  + DatabaseContract.JobOfferTable.DESC + TEXT_TYPE + COMMA
  + DatabaseContract.JobOfferTable.TYPE + TEXT_TYPE +

  COMMA       + DatabaseContract.JobOfferTable.SALARY + TEXT_TYPE +

  COMMA       + DatabaseContract.JobOfferTable.LOCATION + TEXT_TYPE +

  COMMA + DatabaseContract.JobOfferTable.COMPANY_ID +

  INTEGER_TYPE + " )";

  private static final String CREATE_COMPANY_TABLE = "CREATE TABLE "
  + DatabaseContract.CompanyTable.TABLE_NAME + " ("
  + DatabaseContract.CompanyTable.NAME + TEXT_TYPE + COMMA
  + DatabaseContract.CompanyTable.IMAGE_LINK + TEXT_TYPE +  " )";

  /**
  * SQL DELETE TABLE SENTENCES
  */
  public static final String DROP_JOB_OFFER_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.JobOfferTable.TABLE_NAME;
  public static final String DROP_COMPANY_TABLE = "DROP TABLE IF EXISTS "+ DatabaseContract.CompanyTable.TABLE_NAME;

  public DBOpenHelper(Context context){
    super(context, DatabaseContract.DB_NAME, null, DATABASE_VERSION);
  }

  @Override
  public void onCreate(SQLiteDatabase db) {
    db.execSQL(CREATE_JOB_OFFER_TABLE);
    db.execSQL(CREATE_COMPANY_TABLE);
  }

  @Override
  public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    db.execSQL(DROP_COMPANY_TABLE);
    db.execSQL(DROP_JOB_OFFER_TABLE);
    onCreate(db);
  }
}

数据库访问对象

数据库访问对象,通常称为DAO,是一个管理应用程序与数据库所有访问的对象。从概念上讲,它是数据库和我们的应用程序之间的一个类:

数据库访问对象

这是在J2EEJava 2 Enterprise Edition)服务器端通常使用的模式。在这种模式中,数据库的实现可以被更改,并增加一层独立性,从而允许在不需要更改应用程序中任何数据的情况下更改数据库实现。即使我们在 Android 中不更改数据库的实现(它始终是通过SQLiteOpenHelper获取的 SQLite 数据库),使用这种模式仍然是有意义的。从结构的角度来看,我们将在同一个地方拥有所有的数据库访问操作。同时,将 DAO 作为单例,并使用同步方法,可以防止诸如同时从两个不同的地方尝试打开数据库的问题,如果我们正在写入,可能会被锁定。当然,从应用程序的任何地方检索此单例的可能性也使得访问数据库变得非常简单。

在下一节中,我们将了解如何创建一个内容提供者,它可以替代我们的 DAO 对象;然而,如果我们只是想要存储和从数据库中读取数据,实现内容提供者是非常繁琐的。让我们继续MasteringAndroidApp项目,创建一个名为MasteringAndroidDAO的类,它将存储工作机会和公司信息,并从数据库中展示信息,以便拥有一个离线也能工作的应用。

这个类将是一个单例,有两个公共同步方法:一个用于存储工作机会(在工作机会表和公司表中),另一个用于读取它们。即使我们将信息分成两个表,在读取时我们也会再次合并它,这样我们就可以继续使用当前的适配器显示工作机会,而无需进行重大更改。通过这种方式,你将学会如何在查询中连接两个表。

如果一个方法是同步的,我们保证它不能同时从两个地方执行。因此,使用以下代码:

public class MasteringAndroidDAO {

  /**
  * Singleton pattern
  */
  private static MasteringAndroidDAO sInstane = null;

  /**
  * Get an instance of the Database Access Object
  *
  * @return instance
  */
  public static MasteringAndroidDAO getInstance(){
    if (sInstane == null){
      sInstane = new MasteringAndroidDAO();
    }
    return sInstane;
  }

  public synchronized boolean storeOffers(Context context, List<JobOffer> offers){
    //Store offers
  }

  public synchronized List<JobOffer> getOffersFromDB(Context context){
    //Get offers
  }

}

我们将从storeOffers()方法开始。我们需要做的第一件事是使用DatabaseOpenHelper打开数据库,之后我们需要在数据库中开始一个事务。我们将存储一个项目列表,所以为每个项目执行事务是没有意义的。如果我们打开一个事务,执行所有需要的插入操作,然后结束事务,批量提交所有更改要高效得多:

try {
  SQLiteDatabase db = newDBOpenHelper(context).getWritableDatabase();

  db.beginTransaction();
  //insert single job offer
  db.setTransactionSuccessful();
  db.endTransaction();
  db.close();
} catch ( Exception e){
  Log.d("MasteringAndroidDAO",e.toString());
  return false;
}

提示

记得在最后使用db.close()关闭数据库。否则,它将保持打开状态并消耗资源,如果我们尝试再次打开它,将会抛出异常。

如果我们只需向单个表中插入数据,那么只需创建一个ContentValue对象——这是一个基于我们想要存储的列构建的键值对象——并调用db.insert(contentValue)。然而,我们的示例要稍微复杂一些。为了存储一个工作机会,我们需要知道公司 ID,而要获得这个 ID,我们需要询问数据库是否已经存储了该公司。如果没有,我们需要将其存储并知道分配给它的 ID 是什么,因为正如之前提到的,ID 是自动生成并递增的。

为了确定公司是否已经在表中,我们需要执行一个查询,搜索所有行,看是否有任何行与我们要查找的公司名称匹配。有两种执行查询的方法:query()rawQuery()

执行查询

查询需要以下参数:

  • tableColumns:这是投影。我们可能想要返回整个表中我们想要在游标中返回的列。在这种情况下,它将是 null,等同于SELECT * FROM。或者,我们可能只想返回一列,new String[]{"column_name"},甚至是原始查询。(这里,new String[]{SELECT ….})。

  • whereClause:通常使用"column_name > 5"条件;然而,如果参数是动态的,我们使用"column_name > ?"。问号用于指定参数的位置,这些参数将在下面的whereArgs参数中。

  • whereArgs:这是where子句中的参数,将替换问号。

  • groupByhavingorderbylimit):这些是其他参数,如果不用可以设置为 null。

在我们的案例中,这就是我们如何询问数据库中是否存在公司的。它将返回只有一个列的游标,这是我们获取 ID 所需的所有信息:

Cursor cursorCompany = db.query(DatabaseContract.CompanyTable.TABLE_NAME,
  new String[]{"rowid"},
  DatabaseContract.CompanyTable.NAME +" LIKE ?",
  new String[]{offer.getCompany()},
  null,null,null);

使用QueryBuilder而不是rawQuery的好处是它能防止 SQL 注入。同时,它不容易出错。在性能方面,它并没有任何优势,因为它内部创建了rawQuery

使用原始查询

原始查询只是一个带有 SQL 查询的字符串。在我们的示例中,它将是这样的:

String queryString = "SELECT rowid FROM company_table WHERE name LIKE '?'"; 
Cursor c = sqLiteDatabase.rawQuery(queryString, whereArgs);

在大多数情况下,原始查询的可读性更强,需要实现的代码更少。在这种情况下,有不良意图的用户可以在whereArgs变量中添加更多的 SQL 代码以获取更多信息,产生错误或删除任何数据。它不能防止 SQL 注入。

介绍游标

当我们调用query()rawQuery()时,结果在游标中返回。游标是行的集合,有许多方法可以访问和迭代它。当不再使用时,它应该被关闭。

迭代游标最简单的方法是在循环中调用moveToNext(),这个方法在没有下一个元素时会返回 false:

Cursor c = query….
while (c.moveToNext()) {
  String currentName = c.getString(c.getColumnIndex("column_name"));
}

为了读取这些信息,我们有不同的方法,如getString(),它接收需要值的列的索引。

要知道一个公司是否已经在表中,我们可以执行一个查询,这将返回只有一个整数 ID 列的行集合。如果有结果,ID 将在索引为0的列中:

public int findCompanyId(SQLiteDatabase db, JobOffer offer){
  Cursor cursorCompany = db.query(DatabaseContract.CompanyTable.TABLE_NAME,
  new String[]{"rowid"},
  DatabaseContract.CompanyTable.NAME +" LIKE ?",
  new String[]{offer.getCompany()},
  null,null,null);

  int id = -1;

  if (cursorCompany.moveToNext()){
    id = cursorCompany.getInt(0);
  }
  return id;
}

另一个选项是定义公司名称列为唯一,并使用insertWithOnConflict指定忽略冲突。这样,如果公司已经存在于数据库中或者刚刚插入,它将返回 ID:

db.insertWithOnConflict(DATABASE_TABLE, null, initialValues, SQLiteDatabase.CONFLICT_IGNORE);

我们可以为查询创建一个方法,如果查询结果存在,则从游标中获取 ID。如果没有结果,则结果将是-1。在存储工作机会之前,我们将检查公司是否存在。如果不存在,我们将存储公司,ID 将在插入时返回:

public boolean storeOffers(Context context, List<JobOffer> offers){

  try {
    SQLiteDatabase db = new DBOpenHelper(context).getWritableDatabase();

    db.beginTransaction();

    for (JobOffer offer : offers){

      ContentValues cv_company = new ContentValues();
      cv_company.put(DatabaseContract.CompanyTable.NAME, offer.getCompany());
      cv_company.put(DatabaseContract.CompanyTable.IMAGE_LINK,offer.getImageLink());

      int id = findCompanyId(db,offer);

      if (id < 0) {
        id = (int) db.insert(DatabaseContract.CompanyTable.TABLE_NAME,null,cv_company);
      }

      ContentValues cv = new ContentValues();
      cv.put(DatabaseContract.JobOfferTable.TITLE,offer.getTitle());
      cv.put(DatabaseContract.JobOfferTable.DESC,offer.getDescription());
      cv.put(DatabaseContract.JobOfferTable.TYPE, offer.getType());
      cv.put(DatabaseContract.JobOfferTable.DESC, offer.getDescription());
      cv.put(DatabaseContract.JobOfferTable.SALARY,offer.getSalary());
      cv.put(DatabaseContract.JobOfferTable.LOCATION,offer.getLocation());
      cv.put(DatabaseContract.JobOfferTable.COMPANY_ID,id);

      db.insert(DatabaseContract.JobOfferTable.TABLE_NAME,null,cv);
    }

    db.setTransactionSuccessful();
    db.endTransaction();

    db.close();

  } catch ( Exception e){
    Log.d("MasteringAndroidDAO", e.toString());
    return false;
  }

  return true;
}

在测试这个之前,理想的情况是准备好从数据库读取的方法,这样我们就可以检查一切是否正确存储。我们的想法是同时查询两个表,使用连接查询,以便返回包含我们需要所有字段的一个游标。

在 SQL 中,这将是一个SELECT * FROM job_offer_table JOIN company_table ON job_offer_table.company_id = company_table.rowid …查询。

我们需要在使用数据库合约中表名的一个查询中这样做。这将是它的样子:

public List<JobOffer> getOffersFromDB(Context context){

  SQLiteDatabase db = new DBOpenHelper(context).getWritableDatabase();

  String join = DatabaseContract.JobOfferTable.TABLE_NAME + " JOIN " +
  DatabaseContract.CompanyTable.TABLE_NAME + " ON " +
  DatabaseContract.JobOfferTable.TABLE_NAME+"."+DatabaseContract.JobOfferTable.COMPANY_ID
  +" = " + DatabaseContract.CompanyTable.TABLE_NAME+".rowid";

  Cursor cursor = db.query(join,null,null,null,null,null,null);

  List<JobOffer> jobOfferList = new ArrayList<>();

  while (cursor.moveToNext()) {
    //Create job offer from cursor and add it
    //to the list
  }

  cursor.close();
  db.close();

  return jobOfferList;
}

下一步是从游标行创建一个工作机会对象,并将其添加到工作机会列表中:

while (cursor.moveToNext()) {

  JobOffer offer = new JobOffer();
  offer.setTitle(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TABLE_NAME)));
  offer.setDescription(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.DESC)));
  offer.setType(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TYPE)));
  offer.setSalary(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.SALARY)));
  offer.setLocation(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.LOCATION)));
  offer.setCompany(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.NAME)));
  offer.setImageLink(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.IMAGE_LINK)));

  jobOfferList.add(offer);
}

在本例中,我们在添加新数据时将清除数据库。为此,我们将在MasteringAndroidDAO中创建一个方法:

/**
* Remove all offers and companies
*/
public void clearDB(Context context)
{
  SQLiteDatabase db = new DBOpenHelper(context).getWritableDatabase();
  // db.delete(String tableName, String whereClause, String[] whereArgs);
  // If whereClause is null, it will delete all rows.
  db.delete(DatabaseContract.JobOfferTable.TABLE_NAME, null, null);
  db.delete(DatabaseContract.CompanyTable.TABLE_NAME, null, null);
}

一旦数据库访问对象拥有我们需要的所有方法,我们就需要转到ListFragment并实现逻辑。理想的流程是首先显示数据库中的数据,并启动下载获取新的工作机会。在后台,当更新完成时,优惠将被更新,列表也将刷新。我们将通过内容提供者和一个游标加载器来实现这一点,该加载器能够自动将数据库与列表视图连接起来。为了测试 DAO,如果不存在网络连接,我们将简单地从数据库显示数据,或者获取新的工作机会列表。当新列表下载完成后,我们将清除数据库并存储新的优惠。

如果我们想构建一个系统,以保存工作机会的历史记录,而不是清除数据库,我们需要做的是检查是否有来自服务器的新优惠,这些优惠尚未存储在数据库中,并且只保存新优惠。通过创建一个带有 Parse ID 的新列,我们可以轻松地做到这一点,这样我们就可以使用唯一标识符比较工作机会。

为了检查是否有互联网连接,我们将使用以下代码向连接管理器查询:

public boolean isOnline() {
  ConnectivityManager cm =
  (ConnectivityManager) getActivity().getSystemService(Context.CONNECTIVITY_SERVICE);
  NetworkInfo netInfo = cm.getActiveNetworkInfo();
  return netInfo != null && netInfo.isConnectedOrConnecting();
}

onCreateView方法中,我们需要判断是否存在网络连接。如果存在连接,我们可以下载新的优惠列表,这些优惠将展示并存储在数据库中,从而清除之前的优惠:

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
  // Inflate the layout for this fragment
  View view = inflater.inflate(R.layout.fragment_list, container, false);

  mRecyclerView = (RecyclerView) view.findViewById(R.id.my_recycler_view);

  // use this setting to improve performance if you know that changes
  // in content do not change the layout size of the RecyclerView
  mRecyclerView.setHasFixedSize(true);

  // use a linear layout manager
  mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));

  //Retrieve the list of offers

  if (isOnline()){
    retrieveJobOffers();
  } else {
    showOffersFromDB();
  }

  return view;
}

public void retrieveJobOffers(){
  ParseQuery<JobOffer> query = ParseQuery.getQuery("JobOffer");
  query.findInBackground(new FindCallback<JobOffer>() {

    @Override
    public void done(List<JobOffer> jobOffersList, ParseException e) {
      MasteringAndroidDAO.getInstance().clearDB(getActivity());
      MasteringAndroidDAO.getInstance().storeOffers(getActivity(), jobOffersList);
      mListItems = MasteringAndroidDAO.getInstance().getOffersFromDB(getActivity());
      JobOffersAdapter adapter = new JobOffersAdapter(mListItems);
      mRecyclerView.setAdapter(adapter);
    }

  });
}

public void showOffersFromDB(){
  mListItems = MasteringAndroidDAO.getInstance().getOffersFromDB(getActivity());
  JobOffersAdapter adapter = new JobOffersAdapter(mListItems);
  mRecyclerView.setAdapter(adapter);
}

目前,我们将创建一个带有新元素列表的适配器。如果我们想要在屏幕上用新的工作机会更新列表视图,并使用这个方法,它将重新启动适配器,这会使列表在瞬间为空,并将滚动位置移到顶部。我们不应该创建一个适配器来刷新列表;现有的适配器应该更新元素列表。

为此,我们将在适配器中创建一个updateElements()方法,该方法将替换当前的优惠列表,并调用notifiyDataSetChanged(),导致适配器刷新所有元素。如果我们确切知道更新了多少元素,我们可以使用notifyItemInserted()notifyRangeItemInserted()来更新并仅对添加的新元素进行动画处理,这比notifyDataSetChanged()更有效。

没有必要手动将视图与数据同步。Android 为我们提供了CursorLoader机制,它可以直接将列表视图与数据库连接起来。因此,我们需要做的就是将新的优惠存储在数据库中,列表视图将自动反映我们的更改。然而,所有这些自动化都有代价;它需要一个内容提供者才能工作。

内容提供者

内容提供者与 DAO 的概念非常相似;它是数据与应用程序之间的接口,允许不同的应用程序交换信息。我们可以决定它是公开的还是私有的,是否允许其他应用程序从中获取数据,以及它是否只在我们应用程序内部使用。数据可以存储在数据库中,例如我们即将创建的那个。它也可以存储在文件中;例如,如果我们想要访问图库中的视频或图片,我们将使用 Android 内置的媒体内容提供者。另外,它也可以从网络获取:

内容提供者

内容提供者必须在清单中声明,因为它是我们应用程序的一个组件,并且要指定它是否可以被其他应用程序访问,这是由 exported 属性控制的。让我们从创建我们自己的内容提供者开始。

要创建一个内容提供者,请创建一个MAAProvider类并继承ContentProvider。我们将需要实现以下方法:

public class MAAProvider extends ContentProvider {

  @Override
  public boolean onCreate() {
    return false;
  }

  @Override
  public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
    return null;
  }

  @Override
  public Uri insert(Uri uri, ContentValues values) {
    return null;
  }

  @Override
  public int delete(Uri uri, String selection, String[] selectionArgs) {
    return 0;
  }

  @Override
  public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
    return 0;
  }

  @Override
  public String getType(Uri uri) {
    return null;
  }
}

OnCreate方法将在提供者启动时被调用;它将初始化提供者工作所需的所有元素。提供者在应用程序启动时同时启动。系统知道要启动哪个提供者,因为这在清单中定义了。接下来的四个方法是访问和管理数据的方法。最后一个方法返回对象的 MIME 类型。

如我们之前提到的,手机中有不同的内容提供者可供使用;例如,我们可以使用内容提供者访问短信、联系人或图库中的媒体项目。因此,必须有方法来识别和访问它们每一个。这是通过URI统一资源标识符)完成的,它是一个类似于我们在浏览器中访问网站时使用的 URL 的字符串。

URI 由前缀"content://"、紧随其后的字符串标识符(称为权限)组成。它通常是类名加上包名"com.packtpub.masteringandoridapp.MAAProvider",然后是一个斜杠和表名,例如"/company_table"。还可以选择在表内行号后面加上斜杠和数字"/2"

因此,公司表的完整 URI 将是"content://com.packtub.masteringandroidapp.MAAProvider/company_table

具有 ID 编号 2 的公司完整 URI 将是"content://com.packtub.masteringandroidapp.MAAProvider/company_table/2"。这种 URI 在一般情况下表示为company_table/#,其中#将被整数替换。

鉴于我们有两个不同的表和一个通过连接得到的第三个表(可以访问以获取表中的所有元素或获取单行数据),我们有六个可能的 URI:

  • content://com.packtub.masteringandroidapp.MAAProvider/company_table

  • content://com.packtub.masteringandroidapp.MAAProvider/company_job_offer

  • content://com.packtub.masteringandroidapp.MAAProvider/offer_join_company

  • content://com.packtub.masteringandroidapp.MAAProvider/company_table/#

  • content://com.packtub.masteringandroidapp.MAAProvider/company_job_offer/#

  • content://com.packtub.masteringandroidapp.MAAProvider/offer_join_company/#

我们只有一个内容提供者;理论上,这个提供者可以实现所有六个 URI 的queryinsertupdatedeletegetType方法,每个方法都有六种不同的实现。因此,当执行myMAAProvider.insert(URI …)时,我们需要有一个if语句来判断哪个表需要插入,并选择正确的实现方式。类似这样:

@Override
public Uri insert(Uri uri, ContentValues values) {
  if (uri.equals("content://com.packtub.masteringandroidapp.MAAProvider/company_table")){
    //Do an insert in company_table
} else if (uri.equals("content://com.packtub.masteringandroidapp.MAAProvider/offer_table")){
//Do an insert in offer table
} else if ... {
  .
  .
  .
}
}

通过比较这些字符串,您可以看出这似乎是不正确的,如果我们添加一个带有整数结尾的 URI,我们需要一种机制来验证"company_table/2"与通用 URI "company_table/#"相对应。这就是为什么我们有UriMatcherUriMatcher将包含与整数相关联的可能 URL 列表。因此,当它接收到一个 URI 时,它会告诉我们在使用字符串模式时应该使用哪个整数。

创建了UriMatcher并定义了所有可能的情况后,我们可以将可能的情况添加到UriMatcher中,并调用UriMatcher.match(Uri uri),这将返回一个带有情况的整数。我们需要做的就是使用开关来判断我们处于哪种情况:

public class MAAProvider extends ContentProvider {

  public final String authority = "com.packtpub.masteringandroidapp.MAAProvider";

  private UriMatcher mUriMatcher;

  private static final int COMPANY_TABLE = 0;
  private static final int COMPANY_TABLE_ROW = 1;
  private static final int OFFER_TABLE = 2;
  private static final int OFFER_TABLE_ROW = 3;
  private static final int JOIN_TABLE = 4;
  private static final int JOIN_TABLE_ROW = 5;

  @Override
  public boolean onCreate() {
    mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    mUriMatcher.addURI(authority,DatabaseContract.CompanyTable.TABLE_NAME,COMPANY_TABLE);
    mUriMatcher.addURI(authority,DatabaseContract.CompanyTable.TABLE_NAME+"/#",COMPANY_TABLE_ROW);
    mUriMatcher.addURI(authority,DatabaseContract.JobOfferTable.TABLE_NAME,OFFER_TABLE);
    mUriMatcher.addURI(authority,DatabaseContract.JobOfferTable.TABLE_NAME+"/#",OFFER_TABLE_ROW);
    mUriMatcher.addURI(authority,DatabaseContract.OFFER_JOIN_COMPANY,JOIN_TABLE);
    mUriMatcher.addURI(authority,DatabaseContract.OFFER_JOIN_COMPANY+"/#",JOIN_TABLE_ROW);

    mDB = new DBOpenHelper(getContext()).getWritableDatabase();

    return true;
  }

  @Override
  public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
    switch (mUriMatcher.match(uri)){
      case COMPANY_TABLE:
      //Query company table
      break;
      case COMPANY_TABLE_ROW:
      //Query company table by id
      break;
      .
      .

我们可以开始实现查询方法,以获取与公司合并的报价列表,并将其设置到适配器中,以检查到目前为止一切是否运行良好。我们需要有以下几个与数据库相关的变量:

private SQLiteDatabase mDB;

这将在onCreate中如下赋值:

mDB = new DBOpenHelper(getContext()).getWritableDatabase();

同样,在查询方法中,我们需要为六种可能性创建查询,如下所示:

@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
  switch (mUriMatcher.match(uri)){
    case COMPANY_TABLE:
    return mDB.query(DatabaseContract.CompanyTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case COMPANY_TABLE_ROW:
    selection = "rowid LIKE "+uri.getLastPathSegment();
    return mDB.query(DatabaseContract.CompanyTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case OFFER_TABLE:
    return mDB.query(DatabaseContract.JobOfferTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case OFFER_TABLE_ROW:
    selection = "rowid LIKE "+uri.getLastPathSegment();
    return mDB.query(DatabaseContract.JobOfferTable.TABLE_NAME, projection,selection,selectionArgs,null,null,sortOrder);
    case JOIN_TABLE:
    return mDB.query(DBOpenHelper.OFFER_JOIN_COMPANY, projection,selection,selectionArgs,null,null,sortOrder);
    case JOIN_TABLE_ROW:
    selection = "rowid LIKE "+uri.getLastPathSegment();
    return mDB.query(DBOpenHelper.OFFER_JOIN_COMPANY, projection,selection,selectionArgs,null,null,sortOrder);
  }
  return null;
}

我们需要用以下定义的DBOpenHelper.OFFER_JOIN_COMPANY变量这样做:

public static final String OFFER_JOIN_COMPANY = DatabaseContract.JobOfferTable.TABLE_NAME + " JOIN " +
DatabaseContract.CompanyTable.TABLE_NAME + " ON " +
DatabaseContract.JobOfferTable.TABLE_NAME+"."+DatabaseContract.JobOfferTable.COMPANY_ID
+" = " + DatabaseContract.CompanyTable.TABLE_NAME+".rowid";Content Resolver

要访问内容提供者,我们将使用ContentResolver。这是一个通用实例,它提供了对所有可用内容提供者的访问以及 CRUD 操作(创建、读取、更新和删除):

ContentResolver cr = getContentResolver();

要使用内容解析器,我们需要一个指向内容提供者的 URI。我们可以在调用之前从字符串变量中创建它:

Uri uriPath = Uri.parse("content://"+MAAProvider.authority + "/" + DatabaseContract.OFFER_JOIN_COMPANY);
Cursor cursor = cr.query(uriPath, null, null, null, null);

另外,我们可以在提供者中定义一个静态变量列表,以便访问它们。

如果我们现在尝试运行这段代码,将会得到错误:'failed to find provider info for com.packtub.masteringandroidapp.MAAProvider'. 这意味着系统找不到提供者,因为我们还没有将其添加到清单文件中。

要添加一个提供者,我们需要在<application>标签内添加<provider>元素;它需要我们提供者的路径和名称以及权限。在我们的案例中,这两者是相同的:

.
.
.
    <activity
        android:name=".OfferDetailActivity"
        android:label="@string/title_activity_offer_detail" >
    </activity>
    <provider android:name="com.packtpub.masteringandroidapp.MAAProvider"
        android:authorities="com.packtpub.masteringandroidapp.MAAProvider">
    </provider>
</application>

即使我们使用CursorLoader显示数据,并且不使用优惠内容列表,创建一个临时方法来显示内容提供者中的优惠列表也不是一个坏主意。这有助于确保在深入CursorLoader道路之前,内容提供者是可访问的并返回预期的数据:

public void showOffersFromContentProvider(){
  ContentResolver cr = getActivity().getContentResolver();
  Uri uriPath = Uri.parse("content://"+MAAProvider.authority + "/" + DatabaseContract.OFFER_JOIN_COMPANY);
  Cursor cursor = cr.query(uriPath, null, null, null, null);

  List<JobOffer> jobOfferList = new ArrayList<>();
  while (cursor.moveToNext()) {

    JobOffer offer = new JobOffer();
    offer.setTitle(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TITLE)));
    offer.setDescription(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.DESC)));
    offer.setType(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.TYPE)));
    offer.setSalary(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.SALARY)));
    offer.setLocation(cursor.getString(cursor.getColumnIndex(DatabaseContract.JobOfferTable.LOCATION)));
    offer.setCompany(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.NAME)));
    offer.setImageLink(cursor.getString(cursor.getColumnIndex(DatabaseContract.CompanyTable.IMAGE_LINK)));

    jobOfferList.add(offer);
  }
  JobOffersAdapter adapter = new JobOffersAdapter(jobOfferList);
  mRecyclerView.setAdapter(adapter);
}

通过将调用showOffersFromDB()替换为showOffersFromContentProvider(),我们应看到完全相同的信息,并以相同的顺序:

if (isOnline()){
  retrieveJobOffers();
} else {
  showOffersFromContentProvider();
}

一旦创建了提供者,CursorLoader对象就可以很容易地实现。在这个阶段,我们可以说大部分工作已经完成。

同步数据库与 UI

当我们使用CursorLoader与内容提供者配合时,游标中返回的数据与数据库中的数据直接关联,这样数据库中的任何更改都会立即反映在 UI 上。当我们有了这个系统,我们只需要关心将数据存储在数据库中以及更新数据。当我们准备好这个系统后,我们将讨论如何实现流行的下拉刷新系统,以在用户需要时更新工作机会。目标是向 Parse 中添加新的工作机会,下拉刷新列表,并立即看到新元素的到来,所有这些都在后台通过内容提供者处理。

实现CursorLoader

为了实现这个目标,下一步是创建CursorLoader。我们之前在书中讨论过加载器;正如我们提到的,它们是在后台加载数据的机制。这个特定的加载器将返回游标中的数据,并从内容提供者加载。它还在检测到源中的任何更改时刷新数据。

要开始使用CursorLoader,我们的活动或片段—在我们的案例中是FragmentList—需要实现LoaderManager.LoaderCallback<Callback>。这个接口将要求我们实现以下方法:

public class ListFragment extends android.support.v4.app.Fragment implements LoaderManager.LoaderCallbacks<Cursor>

@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
  return null;
}

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {

}

@Override
public void onLoaderReset(Loader<Cursor> loader) {

}

让我们从第一个方法开始—onCreateLoader。这个方法接收一个整数 ID 作为参数,这将是我们的加载器的 ID。我们可以在同一个活动中拥有多个加载器,因此我们将为它们分配一个 ID,以便能够识别它们。我们的加载器将定义为:

public static final int MAA_LOADER = 1;

OnCreateLoader方法将在我们告诉LoaderManager初始化我们的加载器时执行。这可以在onCreateView()中完成:

getLoaderManager().initLoader(MAA_LOADER, null, this);

这种方法需要创建所有可以初始化的不同加载器(它们可以是不同类型的加载器);在我们的案例中,我们只有一个,那就是CursorLoader。它将查询表,并将优惠表与公司表连接起来作为结果。内容 URI 的字符串之前已在MAAProvider中定义:

public static final String JOIN_TABLE_URI =  "content://" + MAAProvider.authority + "/" + DatabaseContract.OFFER_JOIN_COMPANY;
@Override
public Loader<Cursor> onCreateLoader(int loaderID, Bundle bundle)
{
  switch (loaderID) {
    case MAA_LOADER:
    return new CursorLoader(
    getActivity(),   // Parent activity context
    Uri.parse(MAAProvider.JOIN_TABLE_URI),
    // Table to query
    null,            // Projection to return
    null,            // No selection clause
    null,            // No selection arguments
    null             // Default sort order
    );
    default:
    //Invalid ID
    return null;
  }
}

当我们告诉加载器管理器初始化我们的加载器时,它会自动创建并开始运行到数据库的查询;异步地,它会调用我们实现的第二个方法,即onLoadFinished。在这个方法中,例如,我们可以检索游标并显示数据,就像我们之前从内容解析器获取游标时所做的那样。将我们从课程中创建工作机会的代码移动到JobOffer类的静态方法中,我们的onLoadFinished方法将类似于以下内容:

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {

  List<JobOffer> jobOfferList = new ArrayList<>();

  while (cursor.moveToNext()) {
    jobOfferList.add(JobOffer.createJobOfferfromCursor(cursor));
  }

  JobOffersAdapter adapter = new JobOffersAdapter(jobOfferList);
  mRecyclerView.setAdapter(adapter);
}

这个解决方案在后台查询数据库,并异步显示结果,但它仍然远非完美。我们将遍历游标创建对象列表,之后,我们将列表发送到适配器,适配器再次遍历列表以创建元素。如果我们有一个可以直接从游标构建列表的适配器会怎样呢?我们问题的解决方案是存在的,它被称为CursorAdapter。然而,在转向这个解决方案之前,我们需要实现第三个方法,这个方法仍然悬而未决。

第三个方法,onLoaderReset,在数据无效时被调用。例如,如果源发生了变化,这可能会发生。它移除了对游标的引用,防止内存泄漏,通常与CursorAdapter一起使用。这是三个中最容易实现的。在我们的示例中,我们可以将其留空;由于我们不会在方法外部使用游标,因此不会有任何内存泄漏。如果我们使用CursorAdapter,它将有一个在我们onLoadFinished方法之外的引用,我们需要将适配器设置为null

@Override
public void onLoaderReset(Loader<Cursor> loader) {
  //mAdapter.changeCursor(null);
}

RecyclerViewCursorAdapter

CursorAdapter类基于游标创建适配器,旨在与ListsView一起使用。它继承自BaseAdapter

传递给适配器的游标必须有一个名为_id的列。为此,我们不需要更改我们的数据库;我们可以在创建CursorLoader时,简单地将字段从rowid重命名为_id

这是一个基本的CursorAdapter示例:

SimpleCursorAdapter mAdapter =
new SimpleCursorAdapter(
  this,                // Current context
  R.layout.list_item,  // Layout for a single row
  null,                // No Cursor yet
  mFromColumns,        // Cursor columns to use
  mToFields,           // Layout fields to use
  0                    // No flags
);

创建后,我们可以在onLoadFinished中传递新的游标:

mAdapter.changeCursor(cursor);

如果您正在使用ListView,这个解决方案是完美的;不幸的是,RecyclerView使用RecyclerView.Adapter,并且与BaseAdapter不兼容。因此,CursorLoader类不能与RecyclerViews一起使用。

在这一点上,我们有两个选择:一个是寻找开源解决方案,例如CursorRecyclerAdaptergist.github.com/quanturium/46541c81aae2a916e31d#file-cursorrecycleradapter-java)并将此类包含在我们的应用程序中。

第二个选项是创建我们自己的。为此,我们将创建一个名为JobOfferCursorAdapter的类,它继承自RecyclerView.Adapter<JobOffersAdapter.MyViewHolder>。这个类与JobOfferAdapter一样,会有onCreateViewonBindView方法。它们的实现方式相同,除了优惠信息在游标中而不是列表中。为了从游标行获取JobOffer,我们将创建一个名为getItem(int position)的额外方法。除此之外,我们还需要getCount方法,它将返回游标的大小,以及一个changeCursor方法,它将允许我们在适配器中更换游标。请看以下代码:

public class JobOfferCursorAdapter extends RecyclerView.Adapter<JobOfferCursorsAdapter.MyViewHolder>{

  Cursor mDataCursor;

  @Override
  public int getItemCount() {
    return (mDataCursor == null) ? 0 : mDataCursor.getCount();
  }

  public void changeCursor(Cursor newCursor) {
    //If the cursors are the same do nothing
    if (mDataCursor == newCursor){
      return;
    }

    //Swap the cursors
    Cursor previous = mDataCursor;
    mDataCursor = newCursor;

    //Notify the Adapter to update the new data
    if (mDataCursor != null){
      this.notifyDataSetChanged();
    }

    //Close previous cursor
    if (previous != null) {
      previous.close();
    }
  }

  private JobOffer getItem(int position) {
    //To be implemented
    return null;
  }

  @Override
  public JobOfferCursorAdapter.MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    //To be implemented
    return null;
  }

  @Override
  public void onBindViewHolder(MyViewHolder holder, int position) {
    //To be implemented
  }

  private class MyViewHolder..

}

getItem方法需要从游标中的一行获取Joboffer。为此,我们首先需要使用moveToPosition(int position)方法将游标移动到这个位置,之后,我们可以提取这一行的值:

private Object getItem(int position) {
  mDataCursor.moveToPosition(position);
  return JobOffer.createJobOfferfromCursor(mDataCursor);
}

有了这个方法,我们可以基于之前的JobOffersAdapter在适配器上实现其余的功能:

@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
  View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.row_job_offer, parent, false);
  return new MyViewHolder(v);
}

@Override
public void onBindViewHolder(JobOfferCursorAdapter.MyViewHolder holder, int position) {
  JobOffer jobOffer =  getItem(position);
  holder.textViewName.setText(jobOffer.getTitle());
  holder.textViewDescription.setText(jobOffer.getDescription());
}

public class MyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener{

  public TextView textViewName;
  public TextView textViewDescription;

  public  MyViewHolder(View v){
    super(v);
    textViewName = (TextView)v.findViewById(R.id.rowJobOfferTitle);
    textViewDescription = (TextView)v.findViewById(R.id.rowJobOfferDesc);
    v.setOnClickListener(this);
  }

  @Override
  public void onClick(View view) {
    Intent intent = new Intent(view.getContext(), OfferDetailActivity.class);
    JobOffer selectedJobOffer = getItem(getAdapterPosition());
    intent.putExtra("job_title", selectedJobOffer.getTitle());
    intent.putExtra("job_description",selectedJobOffer.getDescription());
    intent.putExtra("job_image",selectedJobOffer.getImageLink());
    view.getContext().startActivity(intent);
  }
}

当我们自定义的CursorAdapter适配到RecyclerView完成后,我们就可以在加载管理器完成时创建游标并设置适当的游标了。在OncreateView中,我们将从服务器检索新数据,并同时用当前数据更新视图:

mAdapter = new JobOfferCursorAdapter();
mRecyclerView.setAdapter(mAdapter);

getLoaderManager().initLoader(MAA_LOADER, null, this);

retrieveJobOffers();

return view;

为了显示数据,我们将在加载管理器完成之后更换游标:

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
  Log.d("ListFragment", "OnLoader Finished :" + cursor.getCount());
  mAdapter.changeCursor(cursor);
}

@Override
public void onLoaderReset(Loader<Cursor> loader) {
  mAdapter.changeCursor(null);
  Log.d("ListFragment", "OnLoader Reset :");
}

当数据库中已有先前数据时,这可以完美工作。但是,如果我们尝试卸载 app 并在第一次运行它,我们会看到列表为空。同时,查看日志,我们可以看到我们正在后台正确地存储新的工作优惠信息:

07-25 16:45:42.796  32059-32059/com.packtpub.masteringandroidapp D/ListFragment﹕ OnLoader Finished :0
07-25 16:45:43.507  32059-32059/com.packtpub.masteringandroidapp D/ListFragment﹕ Storing offers :7

这里发生的情况是,我们数据库中的更改目前没有被检测到,但当我们使用CursorLoaders时,这个问题很容易解决。无需手动注册内容观察者或重新启动加载器;我们可以在游标中设置一个CursorLoader使用的监听器,并在数据库中进行任何更改时通知它。在我们的提供者中,我们可以将通知 URI 设置为游标:

case JOIN_TABLE:
Cursor cursor =  mDB.query(DBOpenHelper.OFFER_JOIN_COMPANY, projection,selection,selectionArgs,null,null,sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;

每当数据库更改时,我们可以调用:

Context.getContentResolver().notifyChange(Uri.parse(MAAProvider.JOIN_TABLE_URI), null);

结果是,CursorLoader将自动刷新列表。如果我们从内容提供者进行插入、更新或删除操作,我们可以在这些操作之前加上这一行来通知内容更改。在我们的示例中,我们将在从 Parse 存储新数据到数据库后简单地手动添加它。你可以使用以下代码来实现这个功能:

public void done(List<JobOffer> jobOffersList, ParseException e) {
  Log.d("ListFragment","Storing offers :"+jobOffersList.size());
  MasteringAndroidDAO.getInstance().clearDB(getActivity());
  MasteringAndroidDAO.getInstance().storeOffers(getActivity(), jobOffersList);
  getActivity().getContentResolver().notifyChange(Uri.parse (MAAProvider.JOIN_TABLE_URI), null);
}

现在我们可以卸载 App 并重新安装,我们会看到在后台下载优惠信息的时候列表会空几秒钟。下载一完成,游标加载器就会刷新列表,所有的优惠信息都会出现。为了锦上添花,我们将实现下拉刷新功能。

介绍使用 SwipeRefreshLayout 的下拉刷新功能

通过这个功能,用户可以在列表视图处于顶部时向上滚动来随时刷新列表。这是在如 Gmail 和 Facebook 等应用中常见的流行功能。

引入 SwipeRefreshLayout 实现下拉刷新

为了实现这一功能,Google 发布了一个名为SwipeRefreshLayout的组件,该组件包含在 v4 支持库中。在此库的修订版 21之前,这被显示为屏幕顶部的水平线,颜色会变化。后来,它被改为一个随着滑动动作旋转的半圆形的圆形。

要使用这个功能,我们需要在视图中用这个元素包裹我们的列表:

<android.support.v4.widget.SwipeRefreshLayout  android:id="@+id/swipeRefreshLayout" android:layout_width="match_parent" android:layout_height="match_parent">
  <android.support.v7.widget.RecyclerView android:id="@+id/my_recycler_view" android:scrollbars="vertical" android:layout_width="match_parent" android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>

我们可以创建一个名为mSwipeRefreshLayout的类变量,并设置一个onRefresh监听器,当用户想要刷新时会调用它:

mSwipeRefreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.swipeRefreshLayout);
mSwipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
  @Override
  public void onRefresh() {
    retrieveJobOffers();
  }
});

当数据下载完成后,我们需要调用setRefresh,并传递false值以停止圆圈无限旋转:

@Override
public void done(List<JobOffer> jobOffersList, ParseException e) {
  Log.d("ListFragment","Storing offers :"+jobOffersList.size());
  MasteringAndroidDAO.getInstance().clearDB(getActivity());
  MasteringAndroidDAO.getInstance().storeOffers(getActivity(), jobOffersList);
  getActivity().getContentResolver().notifyChange(Uri.parse(MAAProvider.JOIN_TABLE_URI), null);
  mSwipeRefreshLayout.setRefreshing(false);
}

刷新时,它应该类似于以下截图:

介绍使用 SwipeRefreshLayout 的下拉刷新

我们还可以通过SwipeRfreshLayoutsetColorScheme()方法在旋转时改变箭头的颜色。只需在 XML 中定义三种颜色,并设置三种不同颜色的 ID:

<resources>
  <color name="orange">#FF9900</color>
  <color name="green">#009900</color>
  <color name="blue">#000099</color> 
</resources>

setColorSchemeResources(R.color.orange, R.color.green, R.color.blue);

我们已经实现了我们的目标。有一个简单的方法可以测试整个系统是否工作正常,从SwipeToRefreshLayout到后台的 Parse 请求、内容提供者、数据库和游标加载器。我们可以打开应用,在列表屏幕上时,前往 Parse 创建一个新的工作机会,然后返回应用并下拉刷新。我们应在刷新后看到新的工作机会出现。

总结

在本章中,你学习了如何创建数据库、使用数据库契约和数据库打开助手。我们了解了 DAO 的模式,并对其进行了基本操作。此外,我们用内容提供者替换了 DAO,解释了 URI 匹配器的工作原理以及如何通过内容解析器访问它。

这使得我们可以将CursorLoader与我们自己的CursorAdapter实现结合使用,该实现与RecyclerView兼容,从而使得 UI 与数据库保持同步。在本章的结尾,我们了解了如何使用流行的下拉刷新功能按需更新内容。

在下一章中,我们将了解如何向我们的应用程序添加推送通知以及分析服务,并概述当前市场上可用的分析服务和推送通知选项之间的差异。