精通安卓应用开发(二)
原文:
zh.annas-archive.org/md5/23E2C896EDA56175BA900FB6F2E21CF8译者:飞龙
第六章:CardView 和材料设计
在本章的第一部分,我们将从用户界面的角度显著改进我们的应用程序,使其看起来更专业,我们从一个新的小部件:CardView 开始。我们将学习如何使用设计时属性,这将提高我们的设计和开发速度,并且我们将使用第三方库轻松地在整个应用程序中包含自定义字体。
第二部分将重点关注设计支持库,将材料设计概念添加到我们的应用程序中,改进标签页,并在职位视图上添加视差效果。在此过程中,我们将阐明工具栏、操作栏和应用程序栏是什么,以及如何从应用程序栏实现向上导航。
-
CardView 和 UI 小贴士:
-
CardView
-
设计时布局属性
-
自定义字体
-
-
设计支持库:
-
TabLayout
-
工具栏、操作栏和应用程序栏
-
CoordinatorLayout
-
向上导航
-
CardView 和 UI 设计小贴士
目前,我们的应用程序以行形式显示职位信息,包含两个文本视图;它展示了所需的信息,我们可以说应用程序目前是好的,并且达到了它的目的。然而,我们仍然可以让应用程序实用,并且同时拥有专业、美观的界面,使我们能够保持原创并与竞争对手不同。例如,为了展示职位信息,我们可以模拟一个带有广告海报的职位公告板。为此,我们可以使用 CardView 小部件,它将赋予其深度和纸质卡片的外观。我们将改变我们应用程序的字体。这样一个简单的改变可以带来很大的不同;当我们把默认字体改为自定义字体时,从用户的角度来看,这个应用程序就是一个定制的版本,开发者关注到了每一个细节。
介绍 CardView
CardView 随 Android 5.0 一起发布。它是一个具有圆角和阴影的视图,从而提供深度感,并模拟卡片。将此与回收视图结合使用,我们可以得到一个外观一致且符合许多应用程序的列表项。以下是一张带有 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 对象的图钉。以下是实现的效果:
应用程序看起来比之前好多了;现在它真成了一个职位公告板。仅仅通过改变外观,展示了相同的信息——同样是带有标题和职位描述的两个TextView——它就从演示应用演变成了可以在 Play 商店完美发布的应用。
我们可以通过更改字体来继续改进这一点,但在介绍设计时布局属性之前,这将使视图设计更加容易和快捷。
设计时的布局属性
在使用设计时属性时,我总是想起我在第一份工作中发生的一个有趣故事。我需要显示联系人列表,因此我在创建联系人视图时使用了虚拟数据,这样在创建视图时可以分配一些文本,以便在设计视图中看到大小、颜色和总体外观。
我创建的联系人名叫Paco el churrero,即弗兰克,炸油条的人。Paco 是弗朗西斯科的昵称,而如果你不知道,churro 是一种油炸面食。不管怎样,这些虚拟数据后来被更正为适当的联系人姓名,当显示联系人列表时,这些联系人是从服务器获取的。我记不清是我急于发布应用,忘记了这个操作,还是我简单地遗漏了它,但应用就这样上线了。我开始处理另一个组件,直到有一天服务器端出现问题,服务器开始发送空白联系人。应用无法用联系人姓名覆盖虚拟数据,结果 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方法中创建它,与java和src同一级别。在assets中创建一个名为fonts的第二个文件夹,并将字体放在那里。在我们的示例中,我们将使用Roboto字体;可以从 Google 字体获取,地址为www.google.com/fonts#UsePlace:use/Collection:Roboto。下载字体后,应用程序结构应与以下截图类似:
字体放置到位后,我们需要从这个字体创建一个Typeface对象,并将其设置为myTextView:
Typeface type = Typeface.createFromAsset(getAssets(),"fonts/Roboto-Regular.ttf"); myTextView.setTypeface(type);
如果我们现在想将相同的字体应用到我们应用程序中的所有组件上,比如标签、标题和职位提供卡,我们不得不在应用程序的不同地方重复相同的代码。除此之外,我们还会遇到性能问题。从资源中创建字体需要访问文件;这是一个昂贵的操作。如果我们改变了适配器中职位标题和职位描述的字体,我们应用程序的视图在滚动时将不再流畅。这带来了一些额外的考虑;例如,我们不得不在一个静态类中一次加载字体,并在整个应用程序中使用它。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"/>
既然我们已经解释了书法的优点,我们可以在我们的应用程序中使用它。在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 2.1 开始的版本。材料设计是随着 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会使用主题中的默认颜色,并且标签的位置是固定的。我们新的标签栏将如下所示:
工具栏、操作栏和应用程序栏
在为我们的应用程序添加动作和动画之前,我们需要明确工具栏、操作栏、应用程序栏和AppBarLayout的概念,因为这些可能会造成一些混淆。
操作栏和应用程序栏是同一个组件;“应用程序栏”只是操作栏在材料设计中获得的新名字。这是固定在我们活动顶部的不透明栏,通常显示应用程序的标题、导航选项,并显示不同的操作。图标的显示与否取决于主题:
自从 Android 3.0 以来,默认使用 Holo 主题或其任何后代,这些主题显示操作栏。
让我们继续下一个概念——工具栏(toolbar)。在 API 21,即 Android Lollipop 中引入,它是操作栏(action bar)的泛化,不必固定在活动顶部。我们可以使用setActionBar()方法指定工具栏是否作为活动的操作栏。这意味着工具栏将根据我们的需求表现为操作栏或非操作栏。
如果我们创建一个工具栏并将其设置为操作栏,我们必须使用带有.NoActionBar选项的主题,以避免在主题默认的操作栏和我们刚转换成操作栏的工具栏之间出现重复。
设计支持库中引入了一个名为AppBarLayout的新元素。它是一个LinearLayout,旨在包含工具栏以基于滚动事件显示动画。我们可以使用app:layout_scrollFlag属性在子项中指定滚动时的行为。AppBarLayout旨在被包含在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类型的对象,我们必须创建一个实现Serializable的JobOffer类。一旦我们这样做,就可以在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>
现在我们准备测试行为。启动应用并滚动到底部。看看图片如何折叠以及工具栏如何固定在顶部。它将与以下截图类似:
我们遗漏了一个属性,以实现动画中的良好效果。仅仅折叠图片还不够;我们需要让图片以平滑的方式消失,由工具栏的背景色替换。
在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">
使用这个属性,应用在展开和折叠时看起来会更好:
我们只需通过改变颜色和为图片添加边距来稍微调整一下应用的风格;我们可以在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 paddingLeft和paddingRight以实现以下效果:
后退导航和向上导航
有两种方法可以导航到上一个屏幕。一种称为后退导航,是通过后退按钮执行的导航,这可以是硬件或软件按钮,具体取决于设备。
向上导航是随着 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);
}
总结
在本章中,我们的应用程序发生了巨大变化;我们完全改变了职位列表,现在它看起来类似于一张张精美的手写纸卡,钉在软木板上。同时,你学习了来自材料设计的概念以及如何使用应用栏和工具栏。设计支持库中还有更多小部件,如InputText或FloatingButton,它们非常容易实现。只需将小部件添加到视图中,这就是为什么我们专注于更复杂的组件,如CoordinatorLayout或CollapsingToolbarLayout。
在下一章中,我们将了解如何下载公司标志、直接通过 URL 发布职位广告、讨论内存管理,并看看如何确保我们的应用程序中没有内存泄漏。
第七章:图片处理与内存管理
在本章中,我们将探讨如何展示从 URL 下载的图片。我们将会讨论如何使用 Android 原生 SDK 以及常用的第三方库来实现这一点。我们将考虑诸如下载、压缩、缓存系统以及内存或磁盘存储等关键概念和特性。
我们还将讨论九宫格是什么以及如何创建它,并通过引入矢量图来讲述不同尺寸和密度文件夹中的 drawables。
最后一个部分将专注于内存管理。在我们的应用中识别内存泄漏是一项关键任务,通常在使用图片时会发生。我们将看看可能导致这些泄漏的常见错误以及如何预防的一般性建议。
-
显示网络图片
-
传统方式
-
Volley ImageDownloader
-
毕加索
-
-
图片
-
矢量图
-
动画矢量图
-
九宫格
-
-
内存管理
- 检测和定位泄漏
-
防止泄漏
下载图片
使用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,设置为 centerInside 或 centerCrop 值会看起来更好。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) { … }
maxWidth和maxHeight参数将用于调整图像大小;如果我们不想调整大小,可以将其值设置为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。它可以同时处理多个请求,并且是我们在上一节中解释的原因下,在列表视图中使用的机制。我们可以创建我们希望它使用的缓存机制——内存或磁盘。
它通过使用一种特殊的ImageView:NetworkImageView来工作。当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 团队带给社区的东西令人难以置信。
如果这还不够,调试模式会在图像中显示指示器,角落里的小三角形,不同的颜色表示我们第一次下载图像(即从网络获取时)、从内存缓存获取时以及从磁盘缓存获取时:
掌握图像处理
在结束关于图像这一章节之前,我们必须介绍本书中的两个概念。你知道,根据屏幕密度,图像可以被放置在多个文件夹中——从低密度的drawable-ldpi到高密度的drawable-hdpi,以及超超超高密度的drawable-xxxhdpi,将来可能还会有更多。当我们这样做时,需要考虑我们是希望在所有屏幕上获得高质量图像,还是一个轻量级的 APK。复制图像将增加我们安装程序的大小。在 Android 5.0 中引入的以下组件将解决这个问题。
矢量图形
这些图形基于矢量图形;矢量图形可以放大或缩小而不损失任何质量。有了这个,我们只需要一个图形资源,无论在什么屏幕上使用,它都会有出色的质量,无论是 Android 手表还是 Android 电视。
矢量图形(Vector drawables)是以与定义形状相同的方式定义的——在 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 版本。
下一个组件可能不会包含在设计支持库中,因此我们需要考虑是否要根据 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 提供了一种快速检查内存状态的方法。在底部窗口中,你会在logcat和ADB日志旁边找到一个名为Memory的标签。
如果你点击我们称为垃圾回收器的小卡车图标,你会看到空闲内存如何增加。
不要将此作为空闲内存的参考,因为堆内存是动态的。这意味着堆内存最初可能是 64 MB;我们有 60 MB 已分配和 4 MB 空闲,但我们再分配 10 MB。堆内存可能会增长,最终我们会有一个 128 MB 的堆内存,其中 70MB 已分配和 58 MB 空闲。
要检测泄漏,我们需要获取已分配内存的引用。不断点击垃圾回收器,并在应用中导航,打开和关闭活动,加载图片,滚动列表,并多次执行这些操作。如果分配的内存持续增长且从不下降,这意味着我们在泄漏内存,阻止了一些资源的回收。我们可以大致确定泄漏发生在哪个活动或片段,因为我们将始终在相同点看到增长(假设我们不止有一个泄漏)。
要更精确地定位来源,我们需要使用Android 设备监视器:
选择你的应用进程,并点击更新堆内存:
选择此项后,我们可以看到对象的分配情况;如果出现位图或线程泄漏,这将是一个很好的线索:
如果我们仍然不清楚是什么在泄漏内存,可以点击转储 HPROF 文件按钮,并使用 Eclipse 的记忆分析工具MAT打开此文件。为此,我们将需要下载 Eclipse。
导入文件后,我们可以双击我们的进程,并点击列出对象,这将识别正在发生的情况。例如,我们可以看到活动中有多少对象以及使用了多少堆内存:
防止泄漏
比起修复内存泄漏,更好的做法是根本不让它发生。如果在开发过程中,我们牢记导致泄漏最常见的原因,这将为我们将来省去许多问题。
Activity 和上下文引用
Activity 的引用是这个问题的主要原因之一。将我们活动的引用发送给下载监听器或事件监听器是非常常见的。如果另一个对象持有我们活动的引用,这将阻止垃圾回收器释放我们的活动。例如,如果我们改变方向,我们的活动默认会再次创建,而具有旧方向的旧活动将被销毁。
记得在我们的 Activity 的onDestroy方法中取消监听器的订阅,并注意你发送 Context 的对象;这是我们 Activity 的强引用。
使用 WeakReference
在 Java 中创建对象时,默认情况下它是以强引用创建的。不同与 null 且具有强引用的对象不会被垃圾回收。
只包含弱引用的对象将在下一个周期被垃圾回收。同一个对象可以有多个引用;因此,如果我们需要临时使用一个对象,可以创建一个指向它的弱引用,当强引用被移除时,它将被垃圾回收。
这是一个包含在 Facebook SDK 源代码中的真实世界示例。他们创建了一个名为ToolTipPopup的自定义弹出窗口,它看起来类似于以下图片:
这个弹出窗口需要一个锚视图,这个锚视图是通过弱引用来引用的:
private final WeakReference<View> mAnchorViewRef;
这背后的原因是,在弹出窗口显示的时候,我们不再需要锚视图了。一旦弹出窗口显示,可以将锚视图设置为 null 或使其消失,这不会影响我们。因此,使用弱引用,如果原始锚视图被销毁并失去了其强引用,它也会在ToolTipPopup类中释放弱引用对象。
总结
在本章中,你学习了如何在不依赖任何第三方库的情况下下载图片,以理解它们的使用方法。对 Volley 和 Picasso 的概览使我们可以准备好实现任何处理完美的应用程序。我们还花了一些时间讨论了添加到我们应用程序中的图片,如矢量可绘制资源和九宫格图片。为了完成本章,我们还了解了如何管理应用程序中的内存问题,更重要的是,如何预防这些问题。
在下一章中,我们将创建一个 SQLite 数据库。我们将通过内容提供者导出这个数据库,并通过CursorLoader与内容提供者同步 UI 数据。
第八章:数据库和加载器
在本章中,我们将按照数据库契约创建一个 SQLite 数据库,并使用名为DAO(数据访问对象)的数据库执行读写操作。我们还将解释查询与原始查询之间的区别。
你将学习什么是内容提供者以及如何创建它,这将允许我们从CursorLoader访问这个数据库。我们将通过内容解析器访问内容提供者,同时查询数据库的不同表格,你将学会如何在内容提供者中使用联接查询。
使用CursorLoader **,**我们可以通过创建一个机制来将列表视图与数据库同步,如果我们存储或修改数据库中的任何数据,这些更改将自动反映在我们的视图中。
最后,我们将添加流行的下拉刷新功能,以便按需更新内容。因此,在本章中,将涵盖以下主题:
-
创建数据库
-
数据库契约
-
数据库开放助手
-
数据库访问对象
-
-
创建和访问内容提供者
-
内容提供者
-
内容解析器
-
-
同步数据库与 UI
-
CursorLoader
-
RecyclerView 和 CursorAdapter
-
-
下拉刷新
创建数据库
为了理解 Android 中的数据库是如何工作的,我们将继续在我们的示例应用MasteringAndroidApp上工作,创建一个数据库来存储工作机会,这些工作机会将用于在离线模式下查看内容。这意味着如果我们打开应用一次,工作机会将被保存在设备上,即使在没有互联网连接的情况下打开,我们也能看到信息。
在 Android 中有四种机制来持久化数据:
-
共享偏好设置:这些偏好设置用于以键值结构存储基本信息
-
内部存储:这种存储保存的是你应用私有的文件
-
外部存储:这种存储保存可以与其他应用共享的文件
-
SQLite 数据库:这个基于流行的 SQL 的数据库允许我们以结构化的方式编写和读取信息
我们可以创建简单的结构,比如单表数据库,也可以创建包含多个表格的复杂结构。我们可以合并不同表格的输出以创建复杂的查询。
我们将创建两个表格,以展示如何使用内容提供者创建联接查询。
将有一个公司表格,包含公司 ID,一些关于它们的信息,如名称、网站、额外信息等。第二个表格将包含工作机会;这也需要包含一个带有公司 ID 的列。如果我们想要一个整洁的结构,而不是一个包含众多字段的大型表格,最好是将公司信息放在公司表格中,将工作机会放在工作表格中,只需引用公司即可。
为了清晰起见,也为了专注于 SQLite,我们不会改变 Parse 中的数据结构。因此,我们将下载内容并手动拆分公司和职位信息,将它们分别插入不同的表中。
我们的公司表将具有以下结构:
| RowId | 名称 | 图片链接 |
|---|---|---|
| 0 | Yahoo | …. |
| 1 | … |
rowId列是 Android 自动添加的,因此在创建表时我们不需要指定这个列。
下表是职位提供表:
| RowId | 标题 | 描述 | 薪水 | 地点 | 类型 | Company_id |
|---|---|---|---|---|---|---|
| 24 | 高级安卓开发.. | 2 倍开发者 | 55.000 | 英国伦敦 | 固定职位 | 1 |
| 25 | 初级安卓开发.. | 有经验的开发者 | 20.000 | 英国伦敦 | 固定职位 | 0 |
我们将创建一个视图,作为这两个表连接的结果;在这里,连接将基于company_id:
| 标题 | 描述 | 薪水 | 地点 | 类型 | 公司 ID | 名称 | 图片链接 |
|---|---|---|---|---|---|---|---|
| 高级安卓开发 | 2 倍开发者.. | 55.000 | 英国伦敦 | 固定职位 | 1 | … | |
| 初级安卓开发 | 有经验的开发者 | 20.000 | 英国伦敦 | 固定职位 | 0 | Yahoo | … |
这个视图将允许我们获取所需的所有数据,每一行都包含完整信息。
数据库契约
数据库契约是一个类,我们在其中定义了数据库的名称以及所有表和列的名称作为常量。
它有两个目的:首先,它是一种一眼就能了解数据库结构的好方法。
要创建数据库包和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;
我们需要创建一个扩展 SQLiteOpenHelper 的 DatabaseOpenHelper 类。在扩展这个类时,要求我们实现两个方法:
@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,是一个管理应用中所有数据库访问的对象。从概念上讲,它是介于数据库和我们的应用之间的一个类:
这是在 J2EE (Java 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子句中的参数,将替换问号。 -
groupBy(having,orderby和limit):这些是其余的参数,如果不用可以设置为 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 实现query、insert、update、delete和getType方法,每个方法都有六种不同的实现。因此,当执行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/#"相对应。这就是我们有UriMatcher的原因。UriMatcher将包含与整数相关联的可能 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);
另外,我们可以在提供者中将 URI 列表定义为静态变量,以便访问它们。
如果我们现在尝试运行这段代码,我们会得到错误,'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对象就可以很容易地实现。在这个阶段,我们可以认为大部分工作已经完成。
将数据库与用户界面同步
当我们使用CursorLoader与内容提供者配合时,游标返回的数据与数据库中的数据直接关联,这样数据库中的任何更改都会立即反映在用户界面上。当我们拥有这套系统运行时,我们只需要关心将数据存储在数据库中以及更新数据。当我们准备好这套系统后,我们将讨论如何实现流行的下拉刷新系统,以在用户需要时更新工作机会。目标是在 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;
当我们告诉LoaderManager初始化我们的加载器时,将执行OnCreateLoader方法。这可以在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);
}
RecyclerView和CursorAdapter
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一起使用。
在这一点上,我们有两个选择:一个是寻找开源解决方案,例如CursorRecyclerAdapter(gist.github.com/quanturium/46541c81aae2a916e31d#file-cursorrecycleradapter-java)并将这个类包含在我们的应用程序中。
第二个选项是创建我们自己的适配器。为此,我们将创建一个名为JobOfferCursorAdapter的类,它继承自RecyclerView.Adapter<JobOffersAdapter.MyViewHolder>。这个类与JobOfferAdapter一样,将具有onCreateView和onBindView方法。它们的实现方式相同,除了职位信息在光标中而不是列表中。为了从光标行获取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);
}
}
当我们自己的适配RecyclerView的CursorAdapter完成后,我们就可以创建光标并在加载器管理器完成时设置适当的光标了。在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 :");
}
当数据库中已有先前数据时,这个方法工作得很好。但是,如果我们尝试卸载应用然后第一次运行它,我们会发现列表是空的。同时,查看日志,我们可以看到我们在后台正确地存储了新的职位信息:
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);
}
现在我们可以卸载应用然后重新安装,我们会发现当职位信息在后台下载时列表会空几秒钟。下载一完成,光标加载器就会刷新列表,所有职位信息都会出现。为了锦上添花,我们将实现下拉刷新功能。
引入通过 SwipeRefreshLayout 下拉刷新功能
通过这个功能,用户可以在列表视图处于顶部时向上滚动,随时刷新列表。这是在如 Gmail 和 Facebook 等应用中常见的流行功能。
为了实现这个功能,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);
}
在刷新时,它应该类似于以下截图:
我们还可以在使用SwipeRfreshLayout和setColorScheme()方法旋转时改变箭头的颜色。只需在 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与我们自己实现的兼容RecyclerView的CursorAdapter,以实现与数据库同步的 UI 系统。为了完成本章,我们学习了如何使用流行的下拉刷新功能按需更新内容。
在下一章中,我们将了解如何向我们的应用程序添加推送通知以及分析服务,并概述当前市场上可用的分析服务和推送通知选项之间的差异。