Xamarin-蓝图-全-
Xamarin 蓝图(全)
原文:
zh.annas-archive.org/md5/dddf23beb92946914b902a145dd9466a译者:飞龙
前言
在我作为移动开发者的旅程中,我与许多不同的开发范式和技术合作过。我在所有移动平台上使用 Java、objective-C、Swift(2 和 3)和 C#构建了移动应用程序。我甚至为我的移动应用程序构建了整个服务器。
我站在这里并不是为了炫耀,也不是为了说我是专家。但我确实相信,我遇到了很多问题,并构建了解决方案,这些方案很多移动开发者都会需要。
我最新的工作是在 Xamarin 中使用原生和 Xamarin.Forms 构建跨平台解决方案。我花费了大量时间来缩小我认为构建任何跨平台移动应用程序的最佳方法。构建良好的架构、结构和流畅的用户体验,同时尽可能多地共享代码。
享受阅读。
本书涵盖的内容
第一章, 构建相册应用程序,通过构建一个从本地相册文件读取并显示到 UITableView 和 ListView 中的 iOS 和 Android 应用程序,为你提供了一个使用 Xamarin 进行原生开发的教程。
第二章, 构建语音播报应用程序,通过构建一个将使用平台语音服务来朗读文本字段中输入的文本的 iOS、Android 和 Windows Phone 应用程序,为你提供了一个 Xamarin.Forms 开发的教程。
第三章, 构建 GPS 定位器应用程序,展示了如何构建一个集成了原生 GPS 位置服务和 Google Maps API 的 Xamarin.Forms 应用程序。我们将涵盖更多关于 IoC 容器、Xamarin.Forms.Maps 库以及 C#异步和后台任务的技术。
第四章, 构建音频播放器应用程序,在本章中,我们将集成 iOS 中的 AVFramework 和 Android 中的 MediaPlayer 框架来处理音频文件的原生音频功能。
第五章, 构建股票列表应用程序,在本章中,我们将通过使用 CustomRenderers、Styles 和 ControlTemplates 来详细说明我们的 XAML 界面。我们还构建了一个简单的 Web 服务,并为我们的移动应用程序设置了一个 JSON 源。
第六章,构建聊天应用程序,在本章中,我们的用户界面将远离 MVVM 设计,并遵循一个新的范式,称为 MVP(模型-视图-表示者)。我们进一步深入后端,设置 SignalR 中心客户端以模拟聊天服务,当消息可用时,数据将即时在服务器和客户端之间发送。另一个关键的关注点是项目架构,花费时间将项目划分为模块,并创建一个分层结构,以最大化跨不同平台的代码共享。
第七章,构建文件存储应用程序,在本章中,我们将使用 Xamarin.Forms 进行更多开发。我们探讨了行为及其与用户界面的使用。我们还使用 Layout
第八章,构建相机应用程序,我们的最后一章将介绍效果和触发器。我们学习如何将它们应用于用户界面,并使用样式使用它们。我们还为原生平台相机构建了多个复杂的 CustomRenderers,着色图像并接收触摸事件。
您需要这本书的内容
Xamarin Studio
要安装 Xamarin Studio 的副本,请访问以下链接:
构建 Windows Phone 应用程序
为了构建 Windows Phone 应用程序,您需要一个安装了 Windows、Microsoft Visual Studio 和 Universal Windows Platform SDK 的计算机。
运行解决方案
您还需要一个 iOS、android 和 windows phone 设备进行测试。如果您没有设备,您将不得不为每个平台安装模拟器。
iOS
模拟器可以通过 XCode 安装。如果您还没有安装 XCode,您将需要安装一个新的副本。
Android
请从以下链接安装Geny Motion的副本:
Windows Phone
UWP SDK 附带 Microsoft Visual Studio 的模拟器。
这本书是为谁而写的
如果您是一位希望为不同平台创建有趣且功能齐全的应用程序的移动开发者,那么这本书是您的理想解决方案。假设您对 Xamarin 和 C#编程有基本了解。
习惯用法
在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"是的,这是我们的AppDelegate文件;注意末尾的.cs。"
代码块设置如下:
private void handleAssetsLoaded (object sender, EventArgs e)
{
_source.UpdateGalleryItems (_imageHandler.CreateGalleryItems());
_tableView.ReloadData ();
}
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“要这样做,我们只需选择文件 | 新建 | 解决方案并选择一个iOS 单视图应用。”
注意
警告或重要注意事项以这种方式出现在框中。
小贴士
小技巧和技巧以这种方式出现。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题上有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的账户中下载此书的示例代码文件。www.packtpub.com。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与勘误表。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书籍的来源。
-
点击代码下载。
文件下载完成后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为[github.com/PacktPublishing/Xamarin-Blueprints](https://github.com/PacktPublishing/CHANGE THIS)。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!
勘误表
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章. 构建画廊应用程序
本章将通过构建一个 iOS 和 Android 应用程序来演示使用 Xamarin 的原生开发,该应用程序将读取你的本地画廊文件,并在UITableView和ListView中显示它们。本章将涵盖以下主题:
预期知识:
-
创建 iOS 配置证书
-
iOS 开发
-
Objective-C
-
创建密钥库
-
Android 开发
-
Java
在本章中,你将学习以下内容:
-
创建 iOS 项目
-
创建 UIViewController 和 UITableView
-
自定义单元格外观
-
创建 Android 项目
-
创建 XML 界面和 ListView
-
共享项目
-
自定义行外观
-
位图函数
-
ALAssetLibrary
-
添加 iOS 照片屏幕
-
添加 Android 照片屏幕
创建 iOS 项目
让我们开始我们的 Xamarin 之旅;我们将从在 Xamarin Studio 中设置我们的 iOS 项目开始:
-
首先,打开 Xamarin Studio 并创建一个新的 iOS 项目。为此,我们只需选择文件 | 新建 | 解决方案,然后选择iOS 单视图应用程序;我们还必须给它一个名称,并添加你想要运行的程序的捆绑 ID。
注意
建议为每个项目创建一个新的捆绑 ID,并为每个项目创建一个开发者配置文件。
-
现在我们已经创建了 iOS 项目,你将被带到以下屏幕:

这看起来是不是很熟悉?是的,这是我们AppDelegate文件;注意末尾的.cs;因为我们使用 C#,所以所有代码文件都将有这个扩展名(不再有.h或.m文件)。
小贴士
在我们继续之前,花几分钟在 IDE 中移动,展开文件夹,并探索项目结构;它与在 XCode 中创建的 iOS 项目非常相似。
创建 UIViewController 和 UITableView
现在我们有了新的 iOS 项目,我们将从创建一个UIViewController开始。右键单击项目文件,选择添加 | 新建文件,然后在左侧框中从iOS菜单选择ViewController:

你会注意到生成了三个文件,一个.xib文件,一个.cs文件和一个.designer.cs文件。我们不需要担心第三个文件;这个文件是基于其他两个文件自动生成的。
小贴士
右键单击项目项并选择在 Finder 中显示,

这将打开 Finder,你将双击GalleryCell.xib文件;这将打开 XCode 中的用户界面设计器。你应该在文档中看到自动插入的文本,以帮助你开始。
首先,我们必须相应地设置我们的命名空间,并使用 using 语句导入我们的库。为了使用 iOS 用户界面元素,我们必须导入UIKit和CoreGraphics库。我们的类将继承UIViewController类,并在其中重写ViewDidLoad函数:
namespace Gallery.iOS
{
using System;
using System.Collections.Generic;
using CoreGraphics;
using UIKit;
public partial class MainController : UIViewController
{
private UITableView _tableView;
private TableSource _source;
private ImageHandler _imageHandler;
public MainController () : base ("MainController", null)
{
_source = new TableSource ();
_imageHandler = new ImageHandler ();
_imageHandler.AssetsLoaded += handleAssetsLoaded;
}
private void handleAssetsLoaded (object sender, EventArgs e)
{
_source.UpdateGalleryItems (_imageHandler.CreateGalleryItems());
_tableView.ReloadData ();
}
public override void ViewDidLoad ()
{
base.ViewDidLoad ();
var width = View.Bounds.Width;
var height = View.Bounds.Height;
tableView = new UITableView(new CGRect(0, 0, width, height));
tableView.AutoresizingMask = UIViewAutoresizing.All;
tableView.Source = _source;
Add (_tableView);
}
}
}
我们创建的第一个 UI 元素是UITableView。这将用于插入到UIViewController的UIView中,并且我们也会检索UIView的宽度和高度值来拉伸UITableView以适应UIViewController的整个边界。我们还必须调用Add来将UITableView插入到UIView中。为了填充列表中的数据,我们需要创建一个UITableSource来包含要显示在列表中的项目列表。我们还需要一个名为GalleryModel的对象;这将是我们要在每个单元格中显示的数据模型。
按照之前的步骤添加两个新的.cs文件;一个将用于创建我们的UITableSource类,另一个用于GalleryModel类。在TableSource.cs中,首先我们必须使用 using 语句导入Foundation库:
using Foundation;
现在是其余的类。记住,我们必须为我们的UITableSource重写特定函数来描述其行为。它还必须包括一个列表来包含将用于每个单元格中显示的数据的项视图模型:
public class TableSource : UITableViewSource
{
protected List<GalleryItem> galleryItems;
protected string cellIdentifier = "GalleryCell";
public TableSource (string[] items)
{
galleryItems = new List<GalleryItem> ();
}
}
我们必须重写NumberOfSections函数;在我们的情况下,它将始终是 1,因为我们没有列表部分:
public override nint NumberOfSections (UITableView tableView)
{
return 1;
}
为了确定列表项的数量,我们返回列表的计数:
public override nint RowsInSection (UITableView tableview, nint section)
{
return galleryItems.Count;
}
然后我们必须添加GetCell函数;这将用于获取用于渲染特定行的UITableViewCell。但在我们这样做之前,我们需要创建一个自定义的UITableViewCell。
自定义单元格的外观
我们现在将设计将出现在TableSource类中每个模型上的单元格。为我们的自定义UITableViewCell添加一个新的.cs文件。
注意
我们不会使用.xib,而是直接在代码中使用单个.cs文件构建用户界面。
现在是实施阶段:
public class GalleryCell: UITableViewCell
{
private UIImageView _imageView;
private UILabel _titleLabel;
private UILabel _dateLabel;
public GalleryCell (string cellId) : base (UITableViewCellStyle.Default, cellId)
{
SelectionStyle = UITableViewCellSelectionStyle.Gray;
_imageView = new UIImageView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
_titleLabel = new UILabel ()
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
_dateLabel = new UILabel ()
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
ContentView.Add (imageView);
ContentView.Add (titleLabel);
ContentView.Add (dateLabel);
}
}
我们的构造函数必须调用基构造函数,因为我们需要用单元格样式和单元格标识符初始化每个单元格。然后我们为每个单元格添加一个UIImageView和两个UILabel,一个用于文件名,一个用于日期。最后,我们将所有三个元素添加到单元格的主要内容视图中。
当我们有我们的初始化器时,我们添加以下内容:
public void UpdateCell (GalleryItem gallery)
{
_imageView.Image = UIImage.LoadFromData (NSData.FromArray (gallery.ImageData));
_titleLabel.Text = gallery.Title;
_dateLabel.Text = gallery.Date;
}
public override void LayoutSubviews ()
{
base.LayoutSubviews ();
ContentView.TranslatesAutoresizingMaskIntoConstraints = false;
// set layout constraints for main view
AddConstraints (NSLayoutConstraint.FromVisualFormat("V:|[imageView(100)]|", NSLayoutFormatOptions.DirectionLeftToRight, null, new NSDictionary("imageView", imageView)));
AddConstraints (NSLayoutConstraint.FromVisualFormat("V:|[titleLabel]|", NSLayoutFormatOptions.DirectionLeftToRight, null, new NSDictionary("titleLabel", titleLabel)));
AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|-10-[imageView(100)]-10-[titleLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("imageView", imageView, "titleLabel", titleLabel)));
AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|-10-[imageView(100)]-10-[dateLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("imageView", imageView, "dateLabel", dateLabel)));
}
我们的第一个函数UpdateCell简单地将模型数据添加到视图中,我们的第二个函数重写了UITableViewCell类的LayoutSubViews方法(相当于UIViewController的ViewDidLoad函数)。
现在我们有了单元格设计,让我们创建视图模型所需的属性。我们只想在我们的GalleryItem模型中存储数据,这意味着我们想将图像存储为字节数组。让我们为项目模型创建一个属性:
namespace Gallery.iOS
{
using System;
public class GalleryItem
{
public byte[] ImageData;
public string ImageUri;
public string Title;
public string Date;
public GalleryItem ()
{
}
}
}
现在回到我们的TableSource类。下一步是实现GetCell函数:
public override UITableViewCell GetCell (UITableView tableView, NSIndexPath indexPath)
{
var cell = (GalleryCell)tableView.DequeueReusableCell (CellIdentifier);
var galleryItem = galleryItems[indexPath.Row];
if (cell == null)
{
// we create a new cell if this row has not been created yet
cell = new GalleryCell (CellIdentifier);
}
cell.UpdateCell (galleryItem);
return cell;
}
注意if语句中的单元格重用;你应该熟悉这种类型的方法,这是重用单元格视图的常见模式,与 Objective-C 实现相同(这是一个非常基本的单元格重用实现)。我们还调用UpdateCell方法来传递所需的GalleryItem数据以在单元格中显示。让我们也为所有单元格设置一个固定的高度。将以下内容添加到你的TableSource类中:
public override nfloat GetHeightForRow (UITableView tableView, NSIndexPath indexPath)
{
return 100;
}
那么,接下来是什么?
public override void ViewDidLoad ()
{
..
table.Source = new TableSource();
..
}
让我们停止开发,看看我们到目前为止取得了什么成果。我们已经创建了我们的第一个UIViewController、UITableView、UITableViewSource和UITableViewCell,并将它们全部绑定在一起。太棒了!
我们现在需要访问手机的本地存储以提取所需的画廊项目。但在我们这样做之前,我们将创建一个 Android 项目并复制我们在 iOS 中完成的事情。
创建一个 Android 项目
我们的第一步是创建一个新的通用 Android 应用程序:

你将首先到达的屏幕是MainActivity。这是我们起始活动,它将填充第一个用户界面;请注意配置属性:
[Activity (Label = "Gallery.Droid", MainLauncher = true, Icon = "@mipmap/icon")]
MainLauncher标志指示起始活动;一个活动必须将此标志设置为true,以便应用程序知道要首先加载哪个活动。icon属性用于设置应用程序图标,而Label属性用于设置出现在导航栏左上角的文本:
namespace Gallery.Droid
{
using Android.App;
using Android.Widget;
using Android.OS;
[Activity (Label = "Gallery.Droid", MainLauncher = true, Icon = "@mipmap/icon")]
public class MainActivity : Activity
{
int count = 1;
protected override void OnCreate (Bundle savedInstanceState)
{
base.OnCreate (savedInstanceState);
// Set our view from the "main" layout resource
SetContentView (Resource.Layout.Main);
}
}
}
我们活动的公式与 Java 相同;我们必须为每个活动重写OnCreate方法,在那里我们将填充第一个 XML 界面Main.xml。
创建 XML 界面和 ListView
我们的起点是main.xml表单;这是我们将在其中创建ListView的地方:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<ListView
android:id="@+id/listView"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_marginBottom="10dp"
android:layout_marginTop="5dp"
android:background="@android:color/transparent"
android:cacheColorHint="@android:color/transparent"
android:divider="#CCCCCC"
android:dividerHeight="1dp"
android:paddingLeft="2dp" />
</LinearLayout>
小贴士
main.xml文件应该已经位于资源 | 布局目录中,所以只需将之前的代码复制粘贴到这个文件中。
太好了!我们现在有了我们的起始活动和界面,所以现在我们必须为我们的ListView创建一个ListAdapter。适配器的工作方式非常类似于UITableSource,我们必须重写函数来确定单元格数据、行设计和列表中的项目数量。
注意
Xamarin Studio 也拥有一个 Android GUI 设计器。
右键点击 Android 项目,为我们的适配器类添加一个新的空类文件。我们的类必须继承BaseAdapter类,并且我们将重写以下函数:
public override long GetItemId(int position);
public override View GetView(int position, View convertView, ViewGroup parent);
在我们继续之前,我们需要为用于在每一行中展示的数据创建一个模型。在我们的 iOS 项目中,我们创建了一个GalleryItem来保存创建每个UIImage所使用的字节数据。这里有两种方法:我们可以创建另一个对象来做与GalleryItem相同的事情,或者更好的是,为什么不用共享项目来重用这个对象呢?
共享项目
我们将深入探讨在不同平台之间共享代码的第一种技术。这正是 Xamarin 希望我们实现并尽可能重用代码。原生开发的最大缺点是两种不同的语言,我们无法重用任何东西。
让我们创建我们的第一个共享项目:

我们共享的项目将用于包含GalleryItem模型,因此我们在这个共享项目中包含的任何代码都可以被 iOS 和 Android 项目访问:

在前面的屏幕截图中,看看解决方案资源管理器,注意共享项目除了.cs代码表之外不包含任何其他内容。共享项目没有任何引用或组件,只是所有平台项目共享的代码。当我们的本地项目引用这些共享项目时,通过using语句引用的任何库都来自本地项目。
现在我们必须让 iOS 和 Android 项目引用共享项目;右键单击引用文件夹并选择编辑引用:

选择您刚刚创建的共享项目,现在我们可以从两个项目中引用GalleryItem对象。
自定义行外观
让我们回到ListAdapter的实现,并设计我们的ListView行外观。打开资源 | 布局文件夹,为单元格外观创建一个新的.xml文件,命名为CustomCell.xml,并复制以下 XML 代码:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:weightSum="4">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/image"
android:layout_width="100dp"
android:layout_height="100dp"
android:adjustViewBounds="true" />
</LinearLayout>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="3"
android:weightSum="2">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
我们正在创建与为 iOS 制作的自定义单元格相同的布局,但在 Android 中我们将使用ImageView和TextView对象。现在我们有了自定义单元格,我们可以实现GetView函数。GetView函数与前面UITableSource实现中的GetCell函数完全相同。打开ListAdapter.cs文件并继续列表适配器实现:
public class ListAdapter : BaseAdapter
{
private List<GalleryItem> _items;
private Activity _context;
public ListAdapter(Activity context) : base()
{
_context = context;
_items = new List<GalleryItem>();
}
public override Java.Lang.Object GetItem (int position)
{
return null;
}
public override long GetItemId(int position)
{
return position;
}
public override int Count
{
get
{
return items.Count;
}
}
}
我们重写Count属性和GetItemId和GetItem函数,以返回我们列表中画廊项的数量。这些重写函数与 Java 中任何继承自BaseAdapter类的重写完全相同。现在来看GetView函数:
public override View GetView(int position, View convertView, ViewGroup parent)
{
View view = convertView; // re-use an existing view, if one is available
if (view == null)
{
// otherwise create a new one
view = context.LayoutInflater.Inflate(Resource.Layout.CustomCell, null);
}
// set image
var imageView = view.FindViewById<ImageView> (Resource.Id.image);
BitmapHelpers.CreateBitmap (imageView, _items [position].ImageData);
// set labels
var titleTextView = view.FindViewById<TextView> (Resource.Id.title);
titleTextView.Text = _items[position].Title;
var dateTextView = view.FindViewById<TextView> (Resource.Id.date);
dateTextView.Text = _items[position].Date;
return view;
}
private async void createBitmap(ImageView imageView, byte[] imageData)
{
try
{
if (imageData != null)
{
var bm = await BitmapFactory.DecodeByteArrayAsync(imageData, 0, imageData.Length);
if (bm != null)
{
imageView.SetImageBitmap(bm);
}
}
}
catch (Exception e)
{
Console.WriteLine ("Bitmap creation failed: " + e);
}
}
注意在GetView函数中,我们正在使用CustomCell布局为每一行;我们还有一个private方法用于从每个模型的字节数组中创建位图。
如果我们查看当前的实现,我们在这里注意到什么?
每次单元格需要再次为视图提供此数据时,我们都在创建位图;这是否高效?不,我们应该尽可能重用位图和内存。
这通常是 Android ListView的一个常见问题。
在滚动时,如何在ListView中跨数百个项高效地重用位图,同时保持平滑移动?我们如何解决这个问题?让我们看看我们如何解决这个问题。
首先,我们需要实现一个名为 ImageHandler 的对象。这将包含从 Android 设备上的所有图库图像中检索字节数组的逻辑。创建一个新文件,命名为 ImageHandler,并开始导入这些命名空间:
namespace Gallery.Droid
{
using System;
using System.Collections.Generic;
using Android.Database;
using Android.Content;
using Android.Provider;
using Gallery.Shared;
public static class ImageHandler
{
}
}
本课程将包含一个名为 GetFiles 的函数,该函数将使用 ContentResolver 接口从任何设备的图库中提取项目,并基于这些项目创建图库项:
public static IEnumerable<GalleryItem> GetFiles(Context context)
{
ContentResolver cr = context.ContentResolver;
string[] columns = new string[]
{
MediaStore.Images.ImageColumns.Id,
MediaStore.Images.ImageColumns.Title,
MediaStore.Images.ImageColumns.Data,
MediaStore.Images.ImageColumns.DateAdded,
MediaStore.Images.ImageColumns.MimeType,
MediaStore.Images.ImageColumns.Size,
};
var cursor = cr.Query(MediaStore.Images.Media.ExternalContentUri, columns, null, null, null);
int columnIndex = cursor.GetColumnIndex(columns[2]);
int index = 0;
// create max 100 items
while (cursor.MoveToNext () && index < 100)
{
index++;
var url = cursor.GetString(columnIndex);
var imageData = createCompressedImageDataFromBitmap (url);
yield return new GalleryItem ()
{
Title = cursor.GetString(1),
Date = cursor.GetString(3),
ImageData = imageData,
ImageUri = url,
};
}
}
使用 ContentResolver(用于访问内容模型),我们将 URI 解析为特定的内容提供者。内容提供者提供对内容的查询,在我们的例子中是图像文件。我们只需从主上下文的 ContentResolver 实例创建一个访问查询,并为查询提供要检索的列数组(例如,文件标题、文件数据、文件大小等)。第一个参数如下:
"MediaStore.Images.Media.ExternalContentUri"
这用于检索查询返回的每个内容片段的 URI。最后,我们现在有一个游标可以遍历,就像一个 Enumerable,它将循环到末尾,直到没有更多项目,并且对于每个迭代,我们提取数据列和 URI 列,并创建一个新的 GalleryItem。您会注意到这里有一个使用 yield 关键字的技巧:如果我们调用此函数,它实际上会从开始到结束返回整个 Enumerable。调用函数开始 for each-ing 对象;函数再次被调用,直到 yields。在调用此函数的返回中,我们得到一个 Enumerable,其中包含从查询中检索的所有项目,作为具有图像信息和本地 URI 的图库项。
位图函数
字节数据怎么办?首先,让我们实现我们的 BitmapHelpers;这些将包括两个全局函数,以帮助进行位图处理:
public static int CalculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight)
{
// Raw height and width of image
float height = options.OutHeight;
float width = options.OutWidth;
double inSampleSize = 1D;
if (height > reqHeight || width > reqWidth)
{
int halfHeight = (int)(height / 2);
int halfWidth = (int)(width / 2);
// Calculate a inSampleSize that is a power of 2 - the decoder will use a value that is a power of two anyway.
while ((halfHeight / inSampleSize) > reqHeight && (halfWidth / inSampleSize) > reqWidth)
{
inSampleSize *= 2;
}
}
return (int)inSampleSize;
}
public static async void CreateBitmap(ImageView imageView, byte[] imageData)
{
try
{
if (imageData != null)
{
var bm = await BitmapFactory.DecodeByteArrayAsync(imageData, 0, imageData.Length);
if (bm != null)
{
imageView.SetImageBitmap(bm);
}
}
}
catch (Exception e)
{
Console.WriteLine ("Bitmap creation failed: " + e);
}
}
我们的第一个函数将通过请求的宽度和高度确定最佳采样大小。这是一种非常好的技术,可以减少加载图像到内存所需的资源。我们的下一个函数用于从传入的字节数据创建 ImageView 的位图。
下一步是使用 private 方法 createCompressedImageDataFromBitmap 创建这些图像数据:
private static byte[] createCompressedImageDataFromBitmap(string url)
{
BitmapFactory.Options options = new BitmapFactory.Options ();
options.InJustDecodeBounds = true;
BitmapFactory.DecodeFile (url, options);
options.InSampleSize = BitmapHelpers.CalculateInSampleSize (options, 1600, 1200);
options.InJustDecodeBounds = false;
Bitmap bm = BitmapFactory.DecodeFile (url, options);
var stream = new MemoryStream ();
bm.Compress (Bitmap.CompressFormat.Jpeg, 80, stream);
return stream.ToArray ();
}
此方法将获取图像 URI 并解码位图选项,以便为提供的尺寸采样可能的最小大小。
我们必须确保设置 InJustDecodeBounds 标志,这样在检索选项信息时,此位图不会被加载到内存中。这种方法对于将图像减小到所需的大小非常有用,从而节省内存。然后我们将图像压缩成 80% 的 JPEG,并将流转换为字节数组,用于我们的 GalleryItem 模型。
现在,让我们回到 adapter 类,并添加此方法以填充我们的 ListAdapter 的项目:
public ListAdapter(Activity context) : base()
{
_context = context;
_items = new List<GalleryItem>();
foreach (var galleryitem in ImageHandler.GetFiles (_context))
{
_items.Add (galleryitem);
}
}
注意
记住我们必须在我们的列表适配器中有一个对主上下文的引用。
现在是拼图的最后一部分,将适配器连接到我们的列表视图。打开 MainActivity.cs 文件,并按如下方式更新代码列表:
public class MainActivity : Activity
{
private ListAdapter _adapter;
protected override void OnCreate (Bundle savedInstanceState)
{
base.OnCreate (savedInstanceState);
SetContentView (Resource.Layout.Main);
_adapter = new ListAdapter (this);
var listView = FindViewById<ListView> (Resource.Id.listView);
listView.Adapter = adapter;
}
}
哇!尝试运行应用程序,并观察ListView如何更新为设备画廊文件夹中的图像。恭喜!您刚刚开发出了您的第一个Xamarin.Android应用程序。现在我们必须为 iOS 版本复制这种方法。
注意
注意在 Android 和 iOS 之间跳转时,切换上下文带来的挑战;这可能会让人困惑。幸运的是,使用 Xamarin,我们只需要一种编程语言,这有助于减少复杂性。
ALAssetLibrary
回到 iOS,我们将使用ALAssetsLibrary类并通过传递组类型ALAssetsGroupType.SavedPhoto、枚举结果代理GroupEnumerator和异常发生时将执行的错误操作来调用Enumerate函数。
首先,为我们的 iOS 图像处理器添加一个新的.cs文件:
注意
我们不会使用一个静态类来处理这个对象。
namespace Gallery.iOS
{
using System;
using System.Threading;
using UIKit;
using AssetsLibrary;
using Foundation;
/// <summary>
/// Image handler.
/// </summary>
public class ImageHandler
{
/// <summary>
/// The asset library.
/// </summary>
ALAssetsLibrary _assetLibrary;
/// <summary>
/// Initializes a new instance of the <see cref="Gallery.iOS.ImageHandler"/> class.
/// </summary>
public ImageHandler ()
{
_assetLibrary = new ALAssetsLibrary();
_assetLibrary.Enumerate(ALAssetsGroupType.SavedPhotos, GroupEnumerator, Console.WriteLine);
}
}
}
在我们的构造函数中,我们创建了新的ALAssetsLibrary实例并调用了Enumerate函数;现在让我们添加GroupEnumerator代理:
private void GroupEnumerator(ALAssetsGroup assetGroup, ref bool shouldStop)
{
if (assetGroup == null)
{
shouldStop = true;
NotifyAssetsLoaded ();
return;
}
if (!shouldStop)
{
assetGroup.Enumerate(AssetEnumerator);
shouldStop = false;
}
}
private void AssetEnumerator(ALAsset asset, nint index, ref bool shouldStop)
{
if (asset == null)
{
shouldStop = true;
return;
}
if (!shouldStop)
{
// add asset name to list
_assets.Add (asset.ToString());
shouldStop = false;
}
}
private void NotifyAssetsLoaded()
{
if (AssetsLoaded != null)
{
AssetsLoaded (this, EventArgs.Empty);
}
}
注意调用通知我们的事件处理器的过程。这表明我们已经到达了asset库的末尾,并且已经检索了我们画廊中的所有ALAsset。现在我们可以提取一个文件名列表,因此我们需要添加另一个同步提取ALAsset对象的函数:
public ALAsset SynchronousGetAsset(string filename)
{
ManualResetEvent waiter = new ManualResetEvent(false);
NSError error = null;
ALAsset result = null;
Exception exception;
ThreadPool.QueueUserWorkItem ((object state) => assetLibrary.AssetForUrl (new NSUrl (filename), (ALAsset asset) =>
{
result = asset;
waiter.Set ();
},
e =>
{
error = e;
waiter.Set ();
}));
if(!waiter.WaitOne (TimeSpan.FromSeconds (10)))
throw new Exception("Error Getting Asset : Timeout, Asset=" + filename);
if (error != null)
throw new Exception (error.Description);
return result;
}
最后,我们需要一个公共函数,该函数将所有字节数组和NSURL拉入一个用于填充UITableView的画廊项目Enumerable中。
提示
由于这只是一个演示,我们只取前 100 个项目。如果您想接受另一个挑战,请删除Take(100),并看看您能否调整代码以更有效地加载更多的图像。
foreach (var file in _assets.Take(100))
{
using (var asset = SynchronousGetAsset (file))
{
if (asset != null)
{
var thumbnail = asset.Thumbnail;
var image = UIImage.FromImage (thumbnail);
var jpegData = image.AsJPEG ().ToArray ();
yield return new GalleryItem ()
{
Title = file,
Date = asset.Date.ToString(),
ImageData = jpegData,
ImageUri = asset.AssetUrl.ToString ()
};
}
}
}
}
让我们更仔细地看看这个函数。我们使用asset库对象提取我们画廊中的所有文件名,然后对于每个文件名,我们提取ALAsset对象,并从每个ALAsset创建一个GalleryItem对象,它从ALAsset中获取图像数据作为字节数组以及资产的NSURL。现在让我们在TableSource内部创建一个ImageHandler实例:
private ImageHandler _imageHandler;
public TableSource (string[] items)
{
_galleryItems = new List<GalleryItem> ();
_imageHandler = new ImageHandler ();
foreach (var galleryItem in imageHandler.GetFiles ())
{
_galleryItems.Add (galleryItem);
}
}
太棒了!现在我们的画廊项目已经准备好在表格中显示了。
对于 iOS 项目的最后一部分,让我们回到我们的AppDelegate.cs文件。我们仍然需要实现FinishedLaunching方法。我们的根控制器将是一个UINavigationController,它将以MainController作为起始的UIViewController:
public override bool FinishedLaunching (UIApplication application, NSDictionary launchOptions)
{
_window = new UIWindow (UIScreen.MainScreen.Bounds);
MainController mainController = new MainController();
var rootNavigationController = new UINavigationController();
rootNavigationController.PushViewController(mainController, false);
_window.RootViewController = rootNavigationController;
_window.MakeKeyAndVisible ();
return true;
}
我们还调整了窗口边界以匹配主屏幕边界,并在MakeKeyAndVisible的末尾调用窗口上的函数。
添加 iOS 照片屏幕
现在我们有了列表页面,我们想要添加另一个UIViewController来显示选定的照片。让我们添加一个新的UIViewController并命名为PhotoController。在PhotoController中,我们将构建一个屏幕,它简单地显示与PhotoCell相同的内容,但更大一些。
首先,让我们从MainController到PhotoController添加导航流程。每当行被选中时,我们将推送一个新的PhotoController。打开TableSource.cs并添加以下内容;在顶部,我们需要添加一个EventHandler:
public event EventHandler<GalleryItem>
ItemSelected;
每当行被选中时,我们希望触发以下事件:
public override void RowSelected (UITableView tableView, NSIndexPath indexPath)
{
if (ItemSelected != null)
{
ItemSelected (this, galleryItems[indexPath.Row]);
}
tableView.DeselectRow (indexPath, true);
}
每当行被选中时,我们希望触发此事件并传递索引路径行的图库项。现在我们需要在MainController类中处理此事件,以便在导航堆栈上推送一个新的PhotoController,但在这样做之前,我们需要实现PhotoController:
public partial class PhotoController : UIViewController
{
/// <summary>
/// The image view.
/// </summary>
private UIImageView _imageView;
/// <summary>
/// The title label.
/// </summary>
private UILabel _titleLabel;
/// <summary>
/// The date label.
/// </summary>
private UILabel _dateLabel;
/// <summary>
/// Initializes a new instance of the <see cref="Gallery.iOS.PhotoController"/> class.
/// </summary>
public PhotoController (ALAsset asset) : base ("PhotoController", null)
{
_imageView = new UIImageView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
ContentMode = UIViewContentMode.ScaleAspectFit
};
_titleLabel = new UILabel ()
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
_dateLabel = new UILabel ()
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
_imageView.Image = new UIImage(asset.DefaultRepresentation.GetFullScreenImage ());
_titleLabel.Text = asset.DefaultRepresentation.Filename;
_dateLabel.Text = asset.Date.ToString();
}
这与我们的GalleryCell展示非常相似,但这个控制器将垂直堆叠元素并强制图像缩放以适应,同时保持图像的正确比例以避免任何扭曲。现在让我们添加ViewDidLoad来布局视图:
public override void ViewDidLoad ()
{
base.ViewDidLoad ();
View.Add (_imageView);
View.Add (_titleLabel);
View.Add (_dateLabel);
// set layout constraints for main view
View.AddConstraints (NSLayoutConstraint.FromVisualFormat("V:|[imageView]-10-[titleLabel(50)]-10-[dateLabel(50)]|", NSLayoutFormatOptions.DirectionLeftToRight, null, new NSDictionary("imageView", imageView, "titleLabel", titleLabel, "dateLabel", dateLabel)));
View.AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|[imageView]|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("imageView", imageView)));
View.AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|[titleLabel]|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("titleLabel", titleLabel)));
View.AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|[dateLabel]|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("dateLabel", dateLabel)));
}
这里没有新的内容;我们只是添加了三个元素,并相应地设置布局约束。我们将所有元素拉伸到视图的整个宽度,并将元素堆叠在页面上,图像视图在顶部,大小根据图像的宽高比动态调整。
最后,最后一步是在行被选中时添加事件处理程序。我们使用ImageHandler通过图库项中的标题(文件名)获取ALAsset,然后将其传递给新PhotoController的构造函数,并更新MainController的构造函数:
public MainController () : base ("MainController", null)
{
_source = new TableSource ();
_source.ItemSelected += (sender, e) =>
{
var asset = _imageHandler.SynchronousGetAsset (e.Title);
NavigationController.PushViewController (new PhotoController (asset), true);
};
_imageHandler = new ImageHandler ();
_imageHandler.AssetsLoaded += handleAssetsLoaded;
}
太棒了!现在运行应用程序并尝试选择列表中的几个项目;您将被导航到一个新的PhotoController,该控制器将显示选中的ALAsset图像及其文件名和日期信息。
添加 Android 照片屏幕
实现单元格选择的相片视图与之前的方法非常相似,尽管在 Android 中我们将使用意图来创建新的活动,该活动随后将展开新的视图以显示图像和详细信息。让我们首先添加一个新的 XML 文件名为photo_view.xml,并粘贴以下代码:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:weightSum="4">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/image_photo"
android:scaleType="centerCrop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:adjustViewBounds="true" />
</LinearLayout>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="3"
android:weightSum="2">
<TextView
android:id="@+id/title_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/date_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
布局与custom_cell.xml表单非常相似,尽管我们将垂直堆叠项目并设置以下两个属性以保持正确的图像宽高比:
android:adjustViewBounds="true"
android:scaleType="centerCrop"
注意
确保 XML 表单不包含任何其他 XML 表单的相同 ID。
现在我们已经有了PhotoActivity的用户界面,让我们添加新的活动:
[Activity (Label = "Gallery.Droid", Icon = "@mipmap/icon")]
public class PhotoActivity : Activity
{
/// <summary>
/// Raises the create event.
/// </summary>
/// <param name="savedInstanceState">Saved instance state.</param>
protected override void OnCreate (Bundle savedInstanceState)
{
base.OnCreate (savedInstanceState);
// Set our view from the "main" layout resource
SetContentView (Resource.Layout.Photo);
var imageData = Intent.GetByteArrayExtra ("ImageData");
var title = Intent.GetStringExtra ("Title") ?? string.Empty;
var date = Intent.GetStringExtra ("Date") ?? string.Empty;
// set image
var imageView = FindViewById<ImageView> (Resource.Id.image_photo);
BitmapHelpers.CreateBitmap (imageView, imageData);
// set labels
var titleTextView = FindViewById<TextView> (Resource.Id.title_photo);
titleTextView.Text = title;
var dateTextView = FindViewById<TextView> (Resource.Id.date_photo);
dateTextView.Text = date;
}
}
看看这个新的活动,我们能看到什么?注意顶部的属性:
[Activity (Label = "Gallery.Droid", Icon = "@mipmap/icon")]
没有找到MainLauncher标签,因为这不是我们的起始活动。然后我们添加intent.GetExtras以获取显示在Photo界面上的图像数据和字符串。
现在我们需要向ListAdapter类添加一个功能:
public GalleryItem GetItemByPosition (int position)
{
return _items[position];
}
当列表中的项目被选中时,我们需要能够访问选中的GalleryItem。我们的下一步是向ListView添加ItemClick委托。打开MainActivity类,并在OnCreate函数中添加以下内容:
listView.ItemClick += (object sender, AdapterView.ItemClickEventArgs e) =>
{
var galleryItem = adapter.GetItemByPosition (e.Position);
var photoActivity = new Intent(this, typeof(PhotoActivity));
photoActivity.PutExtra ("ImageData", galleryItem.ImageData);
photoActivity.PutExtra ("Title", galleryItem.Title);
photoActivity.PutExtra ("Date", galleryItem.Date);
StartActivity(photoActivity);
};
在我们设置列表适配器之后放置此内容。当点击一个项目时,我们只需通过从ItemClickEventArgs传递的位置从我们的适配器中提取画廊项目。一旦我们有了画廊项目,我们就创建新的PhotoActivity意图并传递额外的信息。
就这些;运行应用程序并尝试选择单元格以显示PhotoActivity。
摘要
在本章中,我们使用 Xamarin 原生开发在 iOS 和 Android 上构建了一个画廊应用程序。我们学习了如何在 Xamarin Studio 中设置项目并使用 C#中的原生框架进行编码。在下一章中,我们将使用Xamarin.Forms构建一个文本到语音服务。
尝试改进此代码并使此函数异步;在这个阶段,我们拥有的后台处理越多,越好。这些是我们应该花时间进行的小改进,因为结合所有这些小添加可以真正提高您应用程序的速度。
由于这只是一个演示,我们只将取前 100 项。如果您想接受另一个挑战,请移除Take(100),看看您能否调整代码以更高效地加载数千张图片。
第二章. 构建语音对话应用程序
在本章中,我们介绍使用 Xamarin.Forms 进行开发。我们将构建一个跨平台的应用程序,适用于 iOS、Android 和 Windows Phone,该应用程序集成了原生平台的语音服务,可以朗读从文本字段中输入的文本。
预期知识:
- 微软 Visual Studio。
在本章中,你将学习以下内容:
-
使用
Xamarin.Forms进行跨平台开发 -
设置平台项目
-
设置
SpeechTalk.iOS项目 -
设置
SpeechTalk.Droid项目 -
Xamarin.Forms、Windows Phone 和 Visual Studio -
使用
Xamarin.Forms的控制反转(IoC) -
AutoFac
-
iOS 文本到语音实现
-
绑定
-
Android 文本到语音实现
-
在 Android 中设置 IoC
-
WinPhone 文本到语音实现
-
Windows Phone 的 IoC
-
平台无关样式
使用 Xamarin.Forms 进行跨平台开发
使用 Xamarin 进行跨平台开发的关键要素是代码共享。共享原生代码很好,但我们仍然面临为每个平台编写单独的用户界面代码的问题。Windows 表现框架(WPF)是一个使用基于 XML 的语言可扩展应用程序标记语言(XAML)的演示系统。Xamarin.Forms 使用 WPF 和 模型-视图-视图模型(MVVM)范式从单个 C# 共享代码库构建原生用户界面,同时保持对每个平台所有原生 API 的访问。

上述图表示的是原生架构。我们将所有可共享的代码放在每个平台项目的 Shared C# App Logic 块内(通常是一个共享项目),以便每个平台项目可以访问,即 GalleryItem 类将保存在这里,因为它在两个项目中都是共享的。
那么,在 Xamarin.Forms 中这会是什么样子呢?
由于我们有能力共享用户界面屏幕,因此我们可以共享所有平台之间的整个视图和视图模型代码:

在上述图中,Shared C# App Logic 块中的代码包含在一个 可移植类库(PCL)中,每个原生项目都会导入。Xamarin.Forms 使得共享高达 85% 的代码成为可能。
让我们现在深入开发并设置我们的第一个 Xamarin.Forms 项目。
设置平台项目
在 Xamarin Studio 中,让我们首先设置平台项目。转到 文件 | 新建解决方案,然后从左侧的跨平台菜单中选择一个 Xamarin.Forms 应用程序:

一旦项目创建完成,你将看到同时创建了一个 iOS 项目和一个 Android 项目,以及一个 PCL。
注意
不幸的是,我们无法通过 Xamarin Studio 开发我们的 Windows Phone 应用程序;我们将在 iOS 和 Android 项目之后讨论这个问题。
让我们在 XAML 中创建我们的第一个 ContentPage,在 PCL 上右键单击,创建一个新的 XAML ContentPage,并将其命名为 MainPage:

Xamarin.Forms 提供了完全使用 C# 构建用户界面的选项,但建议您坚持使用 XAML,因为它是一种非常强大的标记语言。XAML 表格所需的代码比 C# 中的用户界面要小得多。
我们还想要创建一个名为 Pages 的新文件夹,并将 MainPage 添加到这个文件夹中。
页面上的第一个元素是一个 网格。网格根据整个屏幕的大小通过行和列来分隔布局。行从上到下工作,列从左到右工作;将以下内容复制到 MainPage.xaml 表格中:
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
x:Class="SpeechTalk.Pages.MainPage">
<ContentPage.Content>
<Grid x:Name="Grid" RowSpacing="0" Padding="10, 10, 10, 10" >
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
</Grid>
</ContentPage.Content>
</ContentPage>
在顶部,我们有一个与 Android 完全相同的 XML 描述标签,它指定了编码和版本。我们有 ContentPage 的声明,带有 XML 命名空间指定属性 xmlns。然后我们指定类名,并添加 ContentPage.Content 标签,我们将在这里创建页面布局。所有这些 XML 标签都是自动生成的;我们唯一做的更改是类的命名空间:
x:Class="SpeechTalk.Pages.MainPage"
插入到 ContentPage.Content 标签之间的网格有三个行和三个列。每个行定义都分配了 Auto,这意味着行的高度基于分配给它的元素。由于我们有三个分配了 Auto 的行,网格将只填充包含元素的高度(类似于 android 中的 wrap_content 标志)。网格将占据页面的整个宽度,因为它的一个列定义设置为 "*",这意味着它将拉伸一列到页面的整个宽度。我们有了基本的页面布局,所以让我们在这里停下来,然后回到项目结构中。
在 SpeechTalk.PCL 中,我们有一个名为 SpeechTalk.cs 的文件;我们应该将其重命名为 App.cs 以匹配类名。在 App.cs 中,这是应用程序的起始点。在应用程序类的构造函数中,您将看到自动设置为这样的 MainPage 属性:
public App ()
{
// The root page of your application
MainPage = new ContentPage {
Content = new StackLayout {
VerticalOptions = LayoutOptions.Center,
Children = {
new Label {
XAlign = TextAlignment.Center,
Text = "Welcome to Xamarin Forms!"
}
}
}
};
}
那么,这里发生了什么?
当项目创建时,我们会自动接收到一个带有 MainPage 属性设置为新的 ContentPage 的 App 类。前面的代码块是一个完全通过 c-sharp 构建的接口示例。我们想要用我们自己的 MainPage 实例来替换它,并将这个新对象设置为 App 类的 MainPage 属性。以下是更新后的构造函数:
public App ()
{
MainPage = new MainPage ();
}
它更干净,您已经可以看到如果我们用 C# 构建复杂用户界面,代码会多么混乱。
设置 SpeechTalk.iOS 项目
让我们也看看 iOS 和 Android 的原生项目设置。打开 AppDelegate.cs 文件;它应该看起来像这样:
[Register ("AppDelegate")]
public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
{
public override bool FinishedLaunching (UIApplication app, NSDictionary options)
{
global::Xamarin.Forms.Forms.Init ();
LoadApplication (new App ());
return base.FinishedLaunching (app, options);
}
}
看看超类:
global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
由于 Xamarin.Forms 1.3.1 及更新的统一 API,我们所有的应用程序代理都应该继承 Xamarin.Forms.Platform.iOS.FormsApplicationDelegate。我们还有一个标准的 FinishedLaunching 函数;在这里,我们必须调用 Forms.Init 来初始化 Xamarin.Forms,然后使用 App 类的新实例调用 LoadApplication。然后我们返回基类的 FinishedLaunching 函数,传递 app 和选项对象。
您可以看到这个 FinishedLaunching 函数是标准应用程序代理函数的覆盖。
注意
我们必须在函数中的任何其他操作之前初始化 forms。
让我们运行 iOS 应用程序并看看会发生什么:

太棒了,一个空白的应用程序。这意味着我们现在已经成功运行了我们的第一个 iOS Xamarin.Forms 项目。
设置 SpeechTalk.Droid 项目
让我们对 Android 也做同样的事情,并相应地设置 Xamarin.Forms。在我们的 Android 项目中,打开 MainActivity.cs 类并查看 OnCreate 函数:
[Activity (Label = "SpeechTalk.Droid", Icon = "@drawable/icon", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsApplicationActivity
{
protected override void OnCreate (Bundle bundle)
{
base.OnCreate (bundle);
global::Xamarin.Forms.Forms.Init (this, bundle);
LoadApplication (new App ());
}
}
MainActivity 类必须继承 Xamarin.Forms.Platform.Android.FormsApplicationActivity;在初始化 Xamarin.Forms 并加载我们新的实例化的应用程序类之前,我们必须调用超类 OnCreate 方法。就这样,我们现在可以运行 Android 应用程序并看到完全相同的结果,一个空白页面。恭喜您,您刚刚共享了您的第一个 Xamarin.Forms 接口。
Xamarin.Forms、Windows Phone 和 Visual Studio
现在让我们看看如何将我们的 MainPage 接口与 Windows Phone 共享。
注意
并非每个人都会将应用程序扩展到 Windows Phone,所以如果您不感兴趣创建 Windows Phone 示例,您可以跳过这部分。
我们将使用 Microsoft Visual Studio,所以打开它并打开我们在 Xamarin Studio 中创建的 SpeechTalk 解决方案文件(SpeechTalk.sln)。这两个 IDE 之间的可移植性非常好;观察解决方案直接导入 Visual Studio 并打开您的 PCL 文件没有任何问题。
小贴士
创建一个 GIT 仓库以帮助控制 Xamarin Studio 和 Visual Studio 之间的持续变化,我们建议为每个章节创建一个 GIT 仓库。
iOS 和 Android 项目可能不兼容,因为我们是在 Xamarin Studio 中创建这些项目的。
小贴士
您可以直接在 Visual Studio 中构建 iOS 和 Android 应用程序,但运行 iOS 应用程序将需要一个 mac 构建宿主。
现在是时候创建一个新的 Windows Phone 项目了:

不幸的是,iOS 和 Android 自动设置将不会在 Windows Phone 项目中完成。所有设置都将手动完成,但这对于指导您完成手动设置是有益的。
我们导入 Xamarin.Forms nuget 包:

现在是时候查看 Windows Phone 项目的 MainPage.xaml 和 MainPage.xaml.cs 文件了。
等一下,我们不是已经做过这样一个了吗?
现在你正在准备一个 Windows Phone 项目,我们可以看到 Xamarin.Forms 中使用的原始 WPF 结构。
打开MainPage.xaml并粘贴以下内容:
<forms:WindowsPhonePage
x:Class="SpeechTalk.WinPhone.MainPage"
mc:Ignorable="d"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid>
</Grid>
</forms:WindowsPhonePage>
小贴士
如果有任何行被下划线标注,请忽略它们;这是 Visual Studio 中的一个问题。
我们在这里能看到什么?
是的,这是 XAML。Windows 应用都是使用 WPF 框架构建的。我们创建Xamarin.Forms元素forms:WindowsPhonePage。在 Windows Phone 项目中打开MainPage.xaml.cs并更新构造函数:
public sealed partial class MainPage
{
public MainPage()
{
InitializeComponent();
NavigationCacheMode = NavigationCacheMode.Required;
LoadApplication(new SpeechTalk.App());
}
}
项目设置相当简单,但我们没有在任何地方调用Forms.Init。在Windows Phone项目中打开App.xaml.cs文件并查找以下代码块:
if (rootFrame == null)
{
// Create a Frame to act as the navigation context and navigate to the first page
rootFrame = new Frame();
// TODO: change this value to a cache size that is appropriate for your application
rootFrame.CacheSize = 1;
Xamarin.Forms.Forms.Init(e);
if (e.PreviousExecutionState == ApplicationExecutionState.Terminated)
{
// TODO: Load state from previously suspended application
}
// Place the frame in the current Window
Window.Current.Content = rootFrame;
}
我们必须手动添加以下这一行:
Xamarin.Forms.Forms.Init(e);
将缓存大小设置为1:
rootFrame.CacheSize = 1;
最后,我们现在需要引用我们在 Xamarin Studio 中之前创建的SpeechTalk PCL 项目:

你可能会遇到将此项目引用到 PCL 默认设置的目标时的问题:

要解决这个问题,打开SpeechTalk PCL 项目并在属性中更新目标配置:

点击更改按钮,确保选中目标前的复选框。这就完成了;尝试构建并运行应用程序。我们应该看到一个空白页面,就像 Android 和 iOS 项目一样。现在我们已经为所有平台制作了一个跨平台应用程序。
现在,让我们来谈谈 IoC 的乐趣。
Xamarin.Forms 的依赖注入(IoC)
控制反转(IoC)原则在编写跨平台应用程序时是一个非常实用的技术。
那么为什么我们应该使用它?
分享 100%的代码会很棒,但这并不完全可能;我们仍然需要一些来自特定平台功能的实现(例如不同的平台服务、硬件、摄像头)。解决这个问题的方法是通过IoC 容器。使用 IoC 原则,我们在共享代码中使用功能抽象,并将抽象的实现传递到共享代码中。我们的 IoC 容器处理对象的依赖树实例化。我们可以将对象注册到它们继承的接口中,并允许容器将注册的对象作为它们的抽象接口传递到依赖树的底部(一直到底层的 PCL)。
那么,我们如何从中受益?
如果我在一个 PCL 项目中需要视图模型调用原生蓝牙服务的方法,该怎么办?
简单来说,我们不能。我们的 PCL 项目对原生侧的蓝牙服务一无所知。我们创建了一个位于 PCL 项目中的接口,创建了一个继承此接口的类,并定义了方法和访问所有所需的原生功能。然后我们通过 IoC 容器将此类注册到继承的接口中,最后在 PCL 项目中解析这个抽象接口。当我们从这个接口在 PCL 中调用函数时,它将调用在原生侧描述的已注册类函数定义:

现在回到我们的SpeechTalk应用程序。由于 PCL 项目无法从原生侧的文本到语音服务共享代码,我们将不得不使用 IoC 来从我们的 PCL 访问原生侧功能。让我们首先声明一个用于文本到语音服务的接口,创建一个名为Services的新文件夹,并添加一个名为ITextToSpeech.cs的新文件用于接口:
public interface ITextToSpeech
{
void Speak (string msg)
}
Autofac
在我们开始实现此接口的不同原生方面之前,让我们首先添加我们的 IoC 容器来处理抽象。有一些 IoC 容器是免费在线的;对于这个例子,我们将使用Autofac。让我们为 PCL、iOS 和 Android 项目添加 NuGet 包:

现在我们已经有了我们的 IoC 容器,让我们构建 iOS 实现。对于每个平台,我们想要创建名为Modules的对象来注册抽象接口。让我们在 PCL 项目中添加一个名为 IoC 的新文件夹,并添加一个名为IoC.cs的新文件:
public static class IoC
{
public static IContainer Container { get; private set; }
private static ContainerBuilder builder;
public static void CreateContainer()
{
builder = new ContainerBuilder();
}
public static void StartContainer()
{
Container = builder.Build();
}
public static void RegisterModule(IModule module)
{
module.Register (builder);
}
public static void RegisterModules(IEnumerable<IModule> modules)
{
foreach (var module in modules)
{
module.Register (builder);
}
}
public static T Resolve<T>()
{
return Container.Resolve<T> ();
}
}
仔细观察,我们使用这个静态类来注册模块、注册类型、解析已注册的类型、创建容器和构建容器。
注意
在注册所有类型之后,必须构建ContainerBuilder。
在初始化应用程序之前,我们必须注册并启动此容器。打开您的AppDelegate.cs文件并更新FinishedLaunching函数:
public override bool FinishedLaunching (UIApplication app, NSDictionary options)
{
global::Xamarin.Forms.Forms.Init ();
InitIoC ();
LoadApplication (new App ());
return base.FinishedLaunching (app, options);
}
private void InitIoC()
{
IoC.CreateContainer ();
IoC.RegisterModule (new IOSModule());
IoC.RegisterModule (new PCLModule());
IoC.StartContainer ();
}
InitIoC函数将首先创建容器,注册模块,并构建 IoC 容器。
注意
在我们开始注册之前,我们的容器必须被创建,并且我们的容器构建器必须在我们可以开始解析之前被构建。
每个模块都有注册函数,这些函数将使用创建的ContainerBuilder来注册类型。
iOS 文本到语音实现
每个模块将检索在整个应用程序生命周期中使用的当前容器。在注册函数内部,我们将注册文本到语音接口的类实现。这将在我们加载其他任何内容之前的应用程序开始时完成。
让我们先从添加 iOS 模块开始。在 iOS 项目中添加一个名为Modules的新文件夹,创建一个名为iOSModule.cs的新文件,并粘贴以下内容:
public class IOSModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<TextToSpeech> ().As<ITextToSpeech> ().SingleInstance ();
}
}
下一步是添加 iOS 文本到语音服务。添加一个名为 Services 的新文件夹,并添加一个名为 TextToSpeech.cs 的新文件。在这个文件中,我们将访问 iOS 的 AVSpeechSynthesizer:
public class TextToSpeech : ITextToSpeech
{
public void Speak (string msg)
{
var speechSynthesizer = new AVSpeechSynthesizer ();
var speechUtterance = new AVSpeechUtterance (msg)
{
Rate = AVSpeechUtterance.MaximumSpeechRate / 4,
Voice = AVSpeechSynthesisVoice.FromLanguage ("en-US"),
Volume = 0.5f,
PitchMultiplier = 1.0f
};
speechSynthesizer.SpeakUtterance (speechUtterance);
}
}
仔细观察这个类,我们将使用语音合成器来生成一个 SpeechUtterance 对象,它包含要说的文本。我们还设置了语言、音量和语速。
注意
注意我们是如何通过 IoC 容器继承将要注册的接口的吗?
由于我们在这个类上编写的是原生代码,因此我们可以访问所有原生 iOS 功能,所以当我们回到 PCL 并在接口中调用 Speak 函数时,前面的代码将执行。
我们的下一步是实现页面视图模型的原则。创建一个名为 ViewModels 的新文件夹,并添加两个新文件,ViewModelBase.cs 和 MainPageViewModel.cs。ViewModelBase 类将是所有视图模型的基础调用,用于处理任何视图模型属性的属性更改事件:
public abstract class ViewModelBase : INotifyPropertyChanged
{
#region Public Events
public event PropertyChangedEventHandler PropertyChanged;
#endregion
#region Methods
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChangedEventHandler handler = this.PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
#endregion
}
让我们更仔细地看看。首先定义的属性是 PropertyChanged EventHandler,它将在任何属性数据更改时触发。注意 # 定义语句的使用;这些对于分割代码块和导航代码表很有用。
注意
这些在处理大型代码表时特别有用。
这个类继承了 INotifyPropertyChanged 接口,这意味着我们必须定义 OnPropertyChanged 函数。这个函数用于触发 PropertyChanged 事件,以通知类内的某个属性已更改数据。现在让我们实现 MainPageViewModel。
我们如何使用 OnPropertyChanged 原则与我们的 MainPageViewModel 结合使用?
在 MainPageViewModel 的每个属性中,我们必须调用 OnPropertyChanged 函数来触发 EventHandler,从而通知特定属性的数据更改。让我们从创建具有私有属性和构造函数的 MainPageViewModel 开始:
public class MainPageViewModel : ViewModelBase
{
#region Private Properties
private readonly ITextToSpeech _textToSpeech;
private string _descriptionMessage = "Enter text and press the 'Speak' button to start speaking";
private string _speakEntryPlaceholder = "Text to speak";
private string _speakText = string.Empty;
private string _speakTitle = "Speak";
private ICommand _speakCommand;
#endregion
#region Constructors
public MainPageViewModel (ITextToSpeech textToSpeech)
{
_textToSpeech = textToSpeech;
_speakCommand = new Command ((c) => _textToSpeech.Speak (this.SpeakText));
}
#endregion
}
这是我们第一次访问 Systems.Windows.Input 库。命令用于 ContentPage 上的 Button 对象;我们将在按钮上设置绑定,以便每当发生点击事件时,这个命令就会执行,运行在构造函数中分配的动作。注意我们是如何传递 TextToSpeech 接口的;这就是 IoC 容器会变得复杂的地方。
现在我们添加视图模型的公共属性,这些属性调用 OnPropertyChanged 函数:
#region Public Properties
public string DescriptionMessage
{
get
{
return _descriptionMessage;
}
set
{
if (value.Equals(_descriptionMessage))
{
return;
}
_descriptionMessage = value;
OnPropertyChanged("DescriptionMessage");
}
}
public string SpeakEntryPlaceholder
{
get
{
return _speakEntryPlaceholder;
}
set
{
if (value.Equals(_speakEntryPlaceholder))
{
return;
}
_speakEntryPlaceholder = value;
OnPropertyChanged("SpeakEntryPlaceholder");
}
}
public string SpeakText
{
get
{
return _speakText;
}
set
{
if (value.Equals(_speakText))
{
return;
}
_speakText = value;
OnPropertyChanged("SpeakText");
}
}
public string SpeakTitle
{
get
{
return _speakTitle;
}
set
{
if (value.Equals(_speakTitle))
{
return;
}
_speakTitle = value;
OnPropertyChanged("SpeakTitle");
}
}
public ICommand SpeakCommand
{
get
{
return _speakCommand;
}
set
{
if (value.Equals(_speakCommand))
{
return;
}
_speakCommand = value;
OnPropertyChanged("SpeakCommand");
}
}
#endregion
就这样!我们得到了第一个视图模型。注意每个属性的get和set方法;它们与函数完全相同,只是呈现方式更优雅。每次我们在public属性内部检索数据时,它都会拉取private属性中的数据,每次我们设置public属性时,如果值与当前值不同,我们将设置包含在private变量中的值,并调用OnPropertyChanged函数来触发基类中的EventHandler。当此事件触发时,它将更新绑定到它的任何视图。
绑定
在 PCL 项目中,我们将运行将视图模型绑定到视图、显示视图模型数据以及通过INotifyPropertyChanged接口传播数据更改的概念。
让我们从MainPage.cs开始,并完成此页面的其余用户界面:
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
x:Class="SpeechTalk.Pages.MainPage"
BackgroundColor="White">
<ContentPage.Content>
<Grid x:Name="Grid" RowSpacing="10" Padding="10, 10, 10, 10" VerticalOptions="Center">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label x:Name="DesciptionLabel" Font="Arial, 20" Grid.Row="0" Grid.Column="0"/>
<Entry x:Name="SpeakEntry" Grid.Row="1" Grid.Column="0"/>
<Button x:Name="SpeakButton" Grid.Row="2" Grid.Column="0"/>
</Grid>
</ContentPage.Content>
</ContentPage>
我们现在有了一个Label、Entry和Button;每个都分配了x:Name、Grid.Row和Grid.Column属性。
注意我们是如何将行和列与之前的定义部分相关联的?
我们还在边界 Grid 上设置了左、上、右和下的填充值;将垂直选项设置为Center;并设置行间距为10。Padding将在 Grid 和ContentPage的整个边界周围放置间隔。
小贴士
Padding(填充)在 HTML 中与边距(margins)的工作方式完全相同。
RowSpacing属性将设置每行之间的间隔;由于每个元素都放置在新的一行中,它们将以10像素的间隔垂直堆叠。由于我们只有 1 列,因此此列宽度将占据整个Grid的宽度,因此每个元素都将占据Grid的全宽。
最后,将Grid的VerticalOptions设置为Center将使所有元素都定位在 Grid 的中心。现在让我们设置MainPage和MainPageViewModel之间的绑定。
创建一个新文件,将其添加到模块文件夹中,命名为PCLModule.cs,并粘贴以下内容:
public class PCLModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<MainPageViewModel> ().SingleInstance();
builer.RegisterType<MainPage> ().SingleInstance();
}
}
等一下...为什么我们要在容器中注册我们的页面和视图模型?
我们不需要对这些内容进行抽象。
在容器中注册视图和视图模型允许我们在构造函数中添加我们的相关视图模型;由于在整个应用程序的生命周期中我们只需要一个视图和视图模型的实例,我们可以将MainPage.xaml.cs文件设置如下:
public partial class MainPage : ContentPage
{
public MainPage ()
{
InitializeComponent ();
}
public MainPage (MainPageViewModel model)
{
BindingContext = model;
InitializeComponent ();
}
}
在容器中注册时创建的MainPageViewModel的实例将在创建MainPage时从构造函数中提取出来。这与我们在构造函数中放置ITextToSpeech抽象的实例所使用的相同技术;它将提取在本地端注册的实例,从而我们可以现在使用此对象来调用将运行native-side代码的函数。
现在回到MainPage.xaml表单,让我们设置属性绑定;更新标签、输入和按钮如下:
<Label x:Name="DesciptionLabel" Text="{Binding DescriptionMessage}" Font="Arial, 20" Grid.Row="0" Grid.Column="0"/>
<Entry x:Name="SpeakEntry" Placeholder="{Binding SpeakEntryPlaceholder}" Text="{Binding SpeakText, Mode=TwoWay}" Grid.Row="1" Grid.Column="0"/>
<Button x:Name="SpeakButton" Text="{Binding SpeakTitle}" Command="{Binding SpeakCommand}" Grid.Row="2" Grid.Column="0"/>
我们已经为标签和输入属性上的文本设置了绑定;注意输入文本属性上设置的双向绑定模式?
这意味着,如果我们从用户界面(因为它是一个文本框,我们将更改 UI 前的数据)或视图模型更改数据,两个端点都会相应地接收到数据更改。我们还设置了按钮上的命令绑定;现在,每次我们在页面上按下此按钮时,它都会运行视图模型中分配给它的操作。
现在所有的编码都完成了,让我们运行应用程序;尝试输入文本并按下 Speak 按钮,听听效果:

干得好!你刚刚完成了你的第一个 iOS Xamarin.Forms 应用程序。
为了进行一些额外的练习,尝试更改 iOS 中 SpeechUtterance 对象的音量和语音属性。
Android 文本到语音实现
现在,让我们为 Android 实现 IoC 容器和文本到语音。首先创建一个文件夹用于 Android 模块和服务,向其中添加两个文件,TextToSpeechDroid.cs 和 DroidModule.cs。
让我们从文本到语音服务开始;对于 TextToSpeechDroid.cs。添加以下内容:
public class TextToSpeechDroid : Java.Lang.Object, ITextToSpeech, Android.Speech.Tts.TextToSpeech.IOnInitListener
{
private Android.Speech.Tts.TextToSpeech _speaker;
private string _toSpeak;
public void Speak (string msg)
{
var ctx = Forms.Context;
_toSpeak = msg;
if (_speaker == null)
{
_speaker = new Android.Speech.Tts.TextToSpeech (ctx, this);
}
else
{
var p = new Dictionary<string,string> ();
speaker.Speak (_toSpeak, QueueMode.Flush, p);
}
}
#region TextToSpeech.IOnInitListener implementation
public void OnInit (OperationResult status)
{
if (status.Equals (OperationResult.Success))
{
var p = new Dictionary<string,string> ();
_speaker.Speak (_toSpeak, QueueMode.Flush, p);
}
}
#endregion
}
这个 IOnInitListener 接口要求实现 OnInit 函数。OnInit 函数被调用以指示 TextToSpeech 引擎初始化完成。然后我们实现接口的 Speak 函数来朗读传入的文本。在函数开始时,我们检查是否已初始化一个新的 TextToSpeech 对象;如果是,则朗读消息。
使用 Android 设置 IoC
现在是 IoC 实现的时候了。它与 iOS 完全一样;让我们添加 Android 模块:
public class DroidModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<TextToSpeechDroid> ().As<ITextToSpeech> ().SingleInstance ();
}
}
很简单,对吧?
现在我们必须在 MainActivity.cs 类中设置 IoC 容器;简单地将 AppDelegate 文件中名为 initIoC 的 iOS 函数复制并粘贴到 MainActivity 类中,然后将 iOSModule 的实例化替换为你的 DroidModule,然后只需在 Xamarin.Forms 初始化之后添加函数调用即可:
protected override void OnCreate (Bundle bundle)
{
base.OnCreate (bundle);
global::Xamarin.Forms.Forms.Init (this, bundle);
InitIoC ();
LoadApplication (new App ());
}
private void InitIoC()
{
IoC.CreateContainer ();
IoC.RegisterModule (new DroidModule());
IoC.RegisterModule (new PCLModule());
IoC.StartContainer ();
}
注意
你可能会在 Android 上遇到让语音工作的问题。你可能需要首先设置的是在 设置 | 控制 | 文本到语音 选项中。如果你还没有安装默认的语音数据,你将在这里安装语音数据。如果你运行了应用程序但没有语音发生,你将需要配置语音数据。
对于 Android 就这些了,现在尝试运行应用程序并听听语音。
WinPhone 文本到语音实现
现在我们回到 Windows Phone 进行最后的实现。看看当你需要在多个平台之间切换时会有多复杂。想象一下,如果我们不得不更改语言并重新编写 IoC 容器,工作量将会更大。不仅如此,使用 IoC 将毫无意义,因为我们无法共享任何代码。
首先,别忘了导入 Autofac 的 nuget 包:

现在我们已经可以访问 Autofac 框架了,让我们继续实现文本到语音服务。首先添加一个名为 Services 的新文件夹,然后添加 TextToSpeechWinPhone.cs 文件并实现它:
public class TextToSpeechWinPhone : ITextToSpeech
{
public async void Speak(string text)
{
MediaElement mediaElement = new MediaElement ();
var synth = new Windows.Media.SpeechSynthesis. SpeechSynthesizer ();
SpeechSynthesisStream stream = await synth.SynthesizeTextToStreamAsync(text);
mediaElement.SetSource(stream, stream.ContentType);
mediaElement.Play();
}
}
仔细观察,你可以看到 MediaElement 的实例化;这是用来播放音频源的。在这个例子中,我们的源是 SpeechSynthesisStream;这个流是通过语音合成器构建的。当我们调用 SynthesizeTextToStreamAsync 函数时,它将基于插入到该函数中的文本生成一个音频流。然后我们将 MediaElement 的源设置为流并调用 Play 函数开始说话。在配置 Windows Phone 时,还有一个额外的配置需要检查,那就是在应用清单文件中检查功能。
Windows Phone 上的 IoC
在 Windows Phone 上实现 IoC 与 iOS 和 Android 非常相似。我们只需在应用程序的起始点添加相同的函数,InitIoC;在这种情况下,是 Windows Phone 项目的 MainPage 构造函数(尽量别弄混),然后我们在 LoadApplication 函数之前调用它:
public MainPage()
{
InitializeComponent();
InitIoC();
NavigationCacheMode = NavigationCacheMode.Required;
LoadApplication(new SpeechTalk.App());
}
private void InitIoC()
{
IoC.CreateContainer();
IoC.RegisterModule(new WinPhoneModule ());
IoC.RegisterModule(new PCLModule ());
IoC.StartContainer();
}
简单!现在我们可以运行 Windows 应用程序了。
平台无关样式
等一下!MainPage 发生了什么——没有按钮,没有文本?
发生在这里的情况是我们没有指定这些元素的颜色,所以文本的默认颜色显示为白色。打开 MainPage.xaml 并相应地更改文本颜色:
<Label x:Name="DesciptionLabel" Text="{Binding DescriptionMessage}" TextColor="Black" Font="Arial, 20" Grid.Row="0" Grid.Column="0"/>
<Button x:Name="SpeakButton" Text="{Binding SpeakTitle}" TextColor="Blue" Command="{Binding SpeakCommand}" Grid.Row="2" Grid.Column="0"/>
可能给 Entry 对象的背景也上色是个好主意,这样我们就能看到文本定义:
<Entry x:Name="SpeakEntry" Placeholder="{Binding SpeakEntryPlaceholder}" BackgroundColor="Silver" Text="{Binding SpeakText, Mode=TwoWay}" Grid.Row="1" Grid.Column="0"/>
再次运行它,看看文本、按钮和输入背景是否显示。
但等等!如果我们不希望这些颜色为 iOS 和 Android 改变,或者我们希望根据平台设置不同的颜色呢?
这里有一个技巧可以尝试:在 MainPage.xaml 表格中,我们将根据是否是 iOS、Android 还是 Windows Phone 来更改输入的背景颜色:
<Entry x:Name="SpeakEntry" Placeholder="{Binding SpeakEntryPlaceholder}" Text="{Binding SpeakText, Mode=TwoWay}" Grid.Row="1" Grid.Column="0">
<Entry.BackgroundColor>
<OnPlatform x:TypeArguments="Color"
Android="White"
WinPhone="Silver"
iOS="White">
</OnPlatform>
</Entry.BackgroundColor>
</Entry>
我们首先指定我们正在更改的属性标记,然后是一个 OnPlatform 标记,在其中我们指定了参数类型,即 Color。让我们更进一步,更改 Button 和 Label 的文本颜色:
<Label x:Name="DesciptionLabel" Text="{Binding DescriptionMessage}" Font="Arial, 20" Grid.Row="0" Grid.Column="0">
<Label.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Black"
WinPhone="Black"
iOS="Black">
</OnPlatform>
</Label.TextColor>
</Label>
<Button x:Name="SpeakButton" Text="{Binding SpeakTitle}" Command="{Binding SpeakCommand}" Grid.Row="2" Grid.Column="0">
<Button.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Navy">
</OnPlatform>
</Button.TextColor>
</Button>
这是在第一页样式之间一个很好的小变化。随着你构建更复杂的 XAML 表格,你可能会发现一些需要改变像素项、改变颜色和执行其他样式以提供额外优势的区域。
让我们到此为止,结束这个项目;现在是时候构建我们的 GPS 定位器了。
摘要
在本章中,我们学习了如何使用 Xamarin.Forms 创建文本到语音服务。我们了解了每个平台的原生语音服务库。在下一章中,我们将学习如何处理后台位置更新事件,并使用经纬度来计算位置。你还将学习如何通过使用 Xamarin.Forms 和 Xamarin.Forms.Maps 在每个平台上实现位置服务。
第三章:构建 GPS 定位器应用程序
在本章中,我们将更深入地探讨代码共享。我们将构建一个集成了原生 GPS 定位服务和 Google Maps API 的 Xamarin.Forms 应用程序。我们将涵盖更多关于 IoC 容器、Xamarin.Forms.Maps 库以及 c-sharp async 和后台任务技术的内容。
预期知识:
-
网络服务
-
JSON
-
Google Maps
-
Google 地理编码 API(拥有 Google 开发者账户会有帮助)
在本章中,你将学习以下内容:
-
核心定位和 GPS
-
使用
Xamarin.Forms进行导航 -
Google Maps 集成
-
将 Google Maps 与
Xamarin.Forms.Maps集成 -
反应式扩展
-
使用 iOS 和
CLLocationManager库进行核心定位 -
Android 和
LocationManager -
创建我们的 Windows 项目
-
Windows Phone 的核心定位服务
-
应用程序类
-
网络服务和数据契约
-
与 Google API 集成
-
创建地理编码网络服务控制器
-
Newtonsoft.Json和 Microsoft HTTP 客户端库 -
ModernHttpClient和客户端消息处理器 -
将 JSON 数据喂入
IObservable框架的更多反应式扩展 -
资源(RESX)文件
-
使用地理编码网络服务器控制器
-
OnNavigatedTo和OnShow -
毕达哥拉斯等角投影
核心定位和 GPS
所有移动平台都可以访问核心定位服务。这些服务是在后台运行的背景任务,在特定的时间间隔内无限期地更新纬度和经度值,直到服务停止。99% 的智能手机都内置了 GPS 跟踪器,允许你将这些纬度和经度值集成到你的应用程序中。
项目设置
让我们直接进入项目设置并创建一个新的 Xamarin.Forms 应用程序。我们将首先使用 Autofac 设置一个 IoC 容器,这与之前的工程完全相同,将 Autofac 导入所有三个项目(PCL、Android 和 iOS)。我们可以从上一个项目中重用 IoC 容器实现中的大量 PCL 代码。
注意
你构建的应用程序越多,解决的问题就越多;为什么总是要反复地重新发明轮子呢?最终,当你构建了多个应用程序后,未来的应用程序将主要是由不同项目的不同部分拼接而成的。
将 IoC、Pages 和 ViewModels 文件夹复制进来,然后开始构建我们的 MainPage:
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
x:Class="Locator.Pages.MainPage"
BackgroundColor="White"
Title="Welcome">
<ContentPage.Content>
<Grid x:Name="Grid" RowSpacing="10" Padding="10, 10, 10, 10" VerticalOptions="Center">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Image x:Name="Image" Source="map.png" HeightRequest="120" WidthRequest="120"
Grid.Row="0" Grid.Column="0"/>
<Label x:Name="DesciptionLabel" Text="{Binding DescriptionMessage}" HorizontalOptions="Center" Font="Arial, 20" Grid.Row="1" Grid.Column="0">
<Label.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Black"
WinPhone="Black"
iOS="Black">
</OnPlatform>
</Label.TextColor>
</Label>
<Button x:Name="LocationButton" Text="{Binding LocationTitle}" Command="{Binding LocationCommand}" BackgroundColor="Silver" Grid.Row="2" Grid.Column="0">
<Button.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Black">
</OnPlatform>
</Button.TextColor>
</Button>
<Button x:Name="ExitButton" Text="{Binding ExitTitle}" Command="{Binding ExitCommand}" BackgroundColor="Silver" Grid.Row="3" Grid.Column="0">
<Button.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Black">
</OnPlatform>
</Button.TextColor>
</Button>
</Grid>
</ContentPage.Content>
</ContentPage>
这与之前的 MainPage 非常相似,但这次我们添加了两个 Buttons,一个 Label 和一个 Image。
注意
在继续阅读之前,看看绑定到每个元素的属性。看看你是否能构建视图模型的属性。
使用 Xamarin.Forms 进行导航
在我们开始构建任何视图模型之前,我们将构建我们的导航系统。Xamarin.Forms为所有平台提供了完整的导航控制,所以你不必担心这一点。但因为我们总是喜欢走难路,我们将向你展示一种技术,以使我们的跨平台结构更加分离,以便使事物更加模块化。使用一个 PCL 项目同时包含视图模型和视图是很好的,但如果我们能够将视图和视图模型分开到两个 PCL 项目中呢?
我们为什么要这样做?
我们当前 PCL 的一个小问题是它完全依赖于Xamarin.Forms。只有我们的 XAML 表单和用户界面依赖于Xamarin.Forms;我们的视图模型不依赖于它。那么,让我们将视图模型从Xamarin.Forms PCL 移动到一个更低级的 PCL 项目中,该项目只依赖于 c-sharp 库。
这是一个很好的技术,可以使 PCL 项目完全分离。在代码共享方面,构建模块化系统是有利的。例如,我们正在构建一个需要登录屏幕、列表视图屏幕和其他大多数应用程序都包含的类似屏幕的新应用程序。因为我们已经有了处理所有网络服务、JSON 处理和属性绑定的视图模型,我们真的需要改变很多吗?现在我们有一个只包含视图模型的低级项目,让我们只提取我们需要的,为视图模型设计用户界面,并将它们绑定在一起。我们不仅可以将这些视图模型用于其他应用程序,而且如果我们想开发一个完全分离的应用程序(例如,一个 WPF 应用程序),我们只需比较所需的屏幕,取相关的视图模型,创建新的用户界面,并将它们绑定在一起。将一切完全分离可以提供完全的即插即用能力,这将大大减少构建类似应用程序所需的开发时间。
让我们通过创建一个新的 PCL 项目并将视图模型复制进来来实现这个模式;命名为Locator.Portable:

我们还希望复制IoC文件夹。
构建导航控制
我们的第一步是创建一个名为enum的文件夹,添加PageNames.cs文件,并复制以下内容:
public enum PageNames
{
MainPage,
MapPage
}
现在,让我们添加一个名为UI的新文件夹,并创建一个名为INavigationService.cs的新文件:
public interface INavigationService
{
Task Navigate(PageNames pageName);
}
然后在Xamarin.Forms PCL (定位器)项目中创建一个新的文件夹,命名为UI,并创建一个名为NavigationService.cs的新文件。NavigationService类将继承INavigationService接口:
public class NavigationService : INavigationService
{
#region INavigationService implementation
public async Task Navigate (PageNames pageName)
{
}
#endregion
}
简单,对吧?导航将在我们想要堆栈导航到页面时使用。在创建一个抽象接口时,就像我们为导航所做的那样,这允许我们在更低级的 PCL 中控制导航。现在,填写其余部分:
public async Task Navigate (PageNames pageName, IDictionary<string, object> navigationParameters)
{
var page = GetPage (pageName);
if (page != null)
{
var navigablePage = page as INavigableXamarinFormsPage;
if (navigablePage != null)
{
await IoC.Resolve<NavigationPage> ().PushAsync (page);
navigablePage.OnNavigatedTo (navigationParameters);
}
}
}
private Page GetPage(PageNames page)
{
switch(page)
{
case PageNames.MainPage:
return IoC.Resolve<MainPage> ();
case PageNames.MapPage:
return IoC.Resolve<MapPage> ();
default:
return null;
}
}
首先,更仔细地看看私有的GetPage函数;每次调用Navigate函数时,它都会根据传入的PageName枚举来检索正确的ContentPage对象(该对象已在IoC容器中注册),并且如果我们找到了正确的页面,就会将其推入导航堆栈。
最后,让我们构建我们的新XamFormsModule以注册页面和导航服务:
public void Register(ContainerBuilder builer)
{
builer.RegisterType<MainPage> ().SingleInstance();
builer.RegisterType<MapPage> ().SingleInstance();
builer.Register (x => new NavigationPage(x.Resolve<MainPage>())).AsSelf().SingleInstance();
builer.RegisterType<NavigationService> ().As<INavigationService>().SingleInstance();
}
我们在整个应用程序的生命周期中注册了一个导航页面,并将起始页面设置为之前注册的一个主页面项。
现在打开App.cs文件并相应地更新它:
public App ()
{
MainPage = IoC.Resolve<NavigationPage> ();
}
现在明白了吗?
IoC 是跨平台应用的一个非常强大的模式。
视图模型导航
现在让我们回到MainPageViewModel,并更新和修改之前章节中的MainPageViewModel,以包含之前在MainPage.xaml中显示的数据绑定所需的属性。首先,让我们实现private属性:
public class MainPageViewModel : ViewModelBase
{
#region Private Properties
private readonly IMethods _methods;
private string _descriptionMessage = "Find your location";
private string _locationTitle = "Find Location";
private string _exitTitle = "Exit";
private ICommand _locationCommand;
private ICommand _exitCommand;
#endregion
}
现在对于Public属性:
#region Public Properties
public string DescriptionMessage
{
get
{
return _descriptionMessage;
}
set
{
if (value.Equals(_descriptionMessage))
{
return;
}
_descriptionMessage = value;
OnPropertyChanged("DescriptionMessage");
}
}
public string LocationTitle
{
get
{
return _locationTitle;
}
set
{
if (value.Equals(_locationTitle))
{
return;
}
_locationTitle = value;
OnPropertyChanged("LocationTitle");
}
}
public string ExitTitle
{
get
{
return _exitTitle;
}
set
{
if (value.Equals(_exitTitle))
{
return;
}
_exitTitle = value;
OnPropertyChanged("ExitTitle");
}
}
public ICommand LocationCommand
{
get
{
return _locationCommand;
}
set
{
if (value.Equals(_locationCommand))
{
return;
}
_locationCommand = value;
OnPropertyChanged("LocationCommand");
}
}
public ICommand ExitCommand
{
get
{
return _exitCommand;
}
set
{
if (value.Equals(_exitCommand))
{
return;
}
_exitCommand = value;
OnPropertyChanged("ExitCommand");
}
}
#endregion
我们是否开始看到相同的模式了?
现在添加构造函数,它将使用我们之前通过IoC容器抽象出的导航服务接口:
#region Constructors
public MainPageViewModel (INavigationService navigation) : base (navigation)
{
}
#endregion
现在是时候向你展示另一个使用 IoC 容器的技巧了。在我们的构造函数中,我们需要能够从Xamarin.Forms库中创建一个新的Command对象。在这里我们很幸运,因为Xamarin.Forms中的命令从System.Windows.Input继承了ICommand接口,所以我们能够将这个对象注册到 IoC 容器中。打开XamFormsModule.cs文件,并更新Register函数以包含以下内容:
builer.RegisterType<Xamarin.Forms.Command> ().As<ICommand>().InstancePerDependency();
提示
注意,我们将此类型注册为InstancePerDependency,因为我们希望在视图模型构造函数中创建命令时每次都得到一个独立的实例。
现在让我们通过MainPageViewModel的构造函数创建一个新的命令;更新构造函数如下:
#region Constructors
public MainPageViewModel (INavigationService navigation, Func<Action, ICommand> commandFactory) : base (navigation)
{
_locationCommand = commandFactory (() => Navigation.Navigate(PageNames.MapPage));
}
#endregion
在构造函数中,我们从IoC容器中拉出一个Func,它接受一个 Action 并返回一个ICommand对象,因为我们已经将这个接口注册到了Xamarin.FormsCommand对象,所以我们将得到一个新的Command,其动作是构造函数中传入的,如下所示:
locationCommand = commandFactory (() => Navigation.Navigate(PageNames.MapPage));
这与我们使用Xamarin.Forms库时做的是完全一样的:
locationCommand = new Command (() => Navigation.Navigate(PageNames.MapPage));
现在我们有一个新的Command集合和Action,当按钮被按下时,可以将新的MapPage推入堆栈:
public class PortableModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<MainPageViewModel> ().SingleInstance();
}
}
现在将我们的新视图模型注册到IoC容器中。为可移植的IoC模块创建一个名为Modules的新文件夹。创建一个名为PortableModule.cs的新文件,并将前面的代码粘贴进去。
使用 Xamarin.Forms.Maps 集成 Google Maps
我们下一步是实现 MapPage;这个页面将显示一个面板,该面板将显示 Google Maps。在这个面板下方,我们还将显示从我们的本地平台核心位置服务检索到的位置信息(纬度、经度、地址等)。要访问这些本地服务,我们需要导入 Xamarin.Forms.Maps:

现在我们已经导入了 Xamarin.Forms.Maps 库,我们可以访问本地的 Google Maps 服务。现在我们可以通过 MapPage.xaml 创建 Map 用户界面元素:
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
x:Class="Locator.Pages.MapPage"
BackgroundColor="White"
Title="Map">
<ContentPage.Content>
<Grid x:Name="Grid" RowSpacing="10" Padding="10, 10, 10, 10">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="80"/>
<RowDefinition Height="60"/>
<RowDefinition Height="60"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<maps:Map x:Name="MapView" IsShowingUser="true" Grid.Row="0" Grid.Column="0"/>
<Label x:Name="AddressLabel" Text="{Binding Address}" TextColor="Black" Grid.Row="1" Grid.Column="0"/>
<Button x:Name="GeolocationButton" Text="{Binding GeolocationButtonTitle}"
Command="{Binding GeolocationCommand}" Grid.Row="2" Grid.Column="0"/>
<Button x:Name="NearestAddressButton" Text="Find Nearest Address"
Command="{Binding NearestAddressCommand}" Grid.Row="3" Grid.Column="0"/>
</Grid>
</ContentPage.Content>
</ContentPage>
看看顶部我们是如何导入 Xamarin.Forms.Maps 库的?
我们在 Grid 中创建了四行,一行用于 Map(这将覆盖大部分屏幕),一行用于显示地址的标签,以及两个按钮用于启动/停止位置更新和从地址列表中查找最近的位置。
那么地址是从哪里来的?
我们现在需要实现核心位置服务;这是一个后台服务,它将根据您的位置发送位置信息。返回的信息非常详细;我们可以描绘确切的经纬度值,以及地址。
注意
核心位置服务可能会耗尽设备电量,因此在使用核心位置时,我们必须管理使用情况,并在需要时打开和关闭它。由于这是一个后台服务,当应用置于后台时,位置服务仍然会运行。
为了开始我们的核心位置实现,我们将创建一个名为 IGeolocator 的抽象地理位置接口,但首先我们需要添加另一个库来处理我们的位置更新。
Reactive Extensions
如果你之前没有听说过 RX 框架,你即将进入一个关于异步的永无止境的兔子洞。RX 给开发者提供了使用 LINQ 风格的查询操作来处理可观察序列中对象的权限。它允许对应用程序不同元素之间基于事件的操作进行完全控制。
在我们的项目中,我们将使用一个 Subject 来处理在本地端接收到的位置事件。在跨平台开发中,因为我们同时在 PCL 和本地级别项目中工作,这涉及到在项目结构中上下传递数据和事件。
我们可以使用标准的 c-sharp 中的 event 框架,但我们将使用一个 Subject 将事件推送到一个可观察序列中,同时我们在较低级别订阅主题以接收和处理这些事件。
让我们从在我们的本地和 PCL 项目中导入 Reactive Extensions 接口开始:

现在让我们创建我们的 IGeolocator 类:
public interface IGeolocator
{
Subject<IPosition> Positions { get; set; }
void Start();
void Stop();
}
注意到 IPosition 接口吗?我们还必须创建一个新的接口,它将存储所有位置信息:
public interface IPosition
{
double Latitude {get; set;}
double Longitude {get; set;}
}
接口设计用于返回这些变量以供Xamarin.Forms geolocator 使用,这样我们就可以拉取地址信息。这些信息由CLLocationManager在每次位置更新时返回。
为什么我们需要为位置信息创建一个接口?
由于这些信息来自不同的本地服务,我们希望创建自己的对象来包含在底层项目中需要的信息。
iOS 和 CLLocationManager 库的核心定位
CLLocationManager用于位置和航向事件的传递;我们必须在我们的 Geolocator 实现中使用此对象,所以让我们开始:
public class GeolocatorIOS : IGeolocator
{
public Subject<IPosition> Positions { get; set; }
}
从我们的接口,我们必须包含Subject。现在让我们实例化CLLocationManager。首先,我们必须导入CoreLocation库:
using CoreLocation;
现在我们通过 IoC 容器创建CLLocationManager时在构造函数中实例化它。根据 iOS 标准,由于 iOS 9 和 iOS 8 的变化,我们必须实现一些单独的调用,以允许位置管理器开始发送位置事件:
public GeolocatorIOS()
{
Positions = new Subject<IPosition> ();
locationManager = new CLLocationManager();
locationManager.PausesLocationUpdatesAutomatically = false;
// iOS 8 has additional permissions requirements
if (UIDevice.CurrentDevice.CheckSystemVersion (8, 0))
{
locationManager.RequestWhenInUseAuthorization ();
}
if (UIDevice.CurrentDevice.CheckSystemVersion (9, 0))
{
locationManager.AllowsBackgroundLocationUpdates = true;
}
}
这不是什么大问题;在 iOS 8 中,我们必须在使用位置管理器之前请求授权。对于 iOS 9,我们还可以设置一些条件设置。在我们的例子中,我们使用了以下设置:
AllowsBackgroundLocationUpdates = true
这允许位置管理器在应用处于后台时继续发送事件。我们还可以这样做:
if (UIDevice.CurrentDevice.CheckSystemVersion (8, 0))
{
locationManager.RequestWhenInUseAuthorization ();
}
这将只允许在应用处于前台时从CLLocationManager接收事件。在使用位置服务时,可以在前台和后台控制位置事件之间更改多个设置。我们想知道我们的应用是否将在后台/前台持续运行更新。大多数时候,我们希望在应用处于前台时进行位置更新以减少电池消耗,但也有一些场景下更新应该在后台继续。
现在让我们继续处理类的其余部分;让我们开始处理位置事件:
private void handleLocationsUpdated (object sender, CLLocationsUpdatedEventArgs e)
{
var location = e.Locations.LastOrDefault ();
if (location != null)
{
Console.WriteLine ("Location updated, position: " + location.Coordinate.Latitude + "-" + location.Coordinate.Longitude);
// fire our custom Location Updated event
Positions.OnNext(new Position()
{
Latitude = location.Coordinate.Latitude,
Longitude = location.Coordinate.Longitude,
});
}
}
每次我们从CLLocationManager接收到位置更新时,都会调用前面的函数。从事件参数CLLocationsUpdatedEventArgs中,我们提取出位置列表;由于CLLocationManager有时会一次性接收到多个更新,我们总是希望获取最后一个位置。然后一旦我们创建了一个新的Position,分配纬度和经度值,并通过调用OnNext函数,我们将一个新的事件推入可观察序列。
我们下一步是向info.plist文件添加一些小的修改。
让我们添加以下键:
<key>NSLocationAlwaysUsageDescription</key>
<string>Can we use your location</string>
key>NSLocationWhenInUseUsageDescription</key>
<string>We are using your location</string>
注意
上述代码来自info.plist文件的源代码。
NSLocationAlwaysUsageDescription和NSLocationWhenInUseUsageDescription键将在请求位置数据访问的警告中显示给用户。我们还必须添加位置的后台模式,我们可以设置 iOS 项目的属性:

现在我们必须实现Start和Stop函数:
public void Start()
{
if (CLLocationManager.LocationServicesEnabled)
{
locationManager.DesiredAccuracy = 1;
locationManager.LocationsUpdated += handleLocationsUpdated;
locationManager.StartUpdatingLocation();
}
}
public void Stop()
{
locationManager.LocationsUpdated -= handleLocationsUpdated;
locationManager.StopUpdatingLocation();
}
Start函数将检查位置服务是否已启用,分配LocationsUpdated事件,并开始位置更新:
public void Register(ContainerBuilder builer)
{
builer.RegisterType<GeolocatorIOS>().As<IGeolocator>().SingleInstance();
}
Stop函数将不会做更多的事情,只是停止位置更新并移除事件处理器。这就是 iOS 地理定位器的全部内容。接下来,我们必须通过 IoC 容器注册这个接口。
处理位置更新
我们下一步是构建MapPageViewModel;这个视图模型将包含我们刚刚构建的IGeolocator。我们还将监听来自可观察序列的位置更新,并处理纬度和经度值以收集地址详情。
让我们从构造函数开始:
public MapPageViewModel (INavigationService navigation, IGeolocator geolocator, Func<Action, ICommand> commandFactory,
IGeocodingWebServiceController geocodingWebServiceController) : base (navigation)
{
_geolocator = geolocator;
_geocodingWebServiceController = geocodingWebServiceController;
_nearestAddressCommand = commandFactory(() => FindNearestSite());
_geolocationCommand = commandFactory(() =>
{
if (_geolocationUpdating)
{
geolocator.Stop();
}
else
{
geolocator.Start();
}
GeolocationButtonTitle = _geolocationUpdating ? "Start" : "Stop";
_geolocationUpdating = !_geolocationUpdating;
});
_positions = new List<IPosition> ();
LocationUpdates = new Subject<IPosition> ();
ClosestUpdates = new Subject<IPosition> ();
}
我们的构造函数将检索导航服务和地理定位器。注意我们如何分配geolocator类:
_geolocator = geolocator;
构造函数还将负责创建地图页面上两个按钮的命令。通常,需要从 IoC 容器中获取对象的视图模型通常被分配为只读属性,因为它们永远不会改变。我们希望属性名与构造函数参数中的项完全相同:
private readonly IGeolocator _geolocator;
现在让我们创建我们的私有属性:
#region Private Properties
private IDisposable _subscriptions;
private readonly IGeolocator _geolocator;
private string _address;
#endregion
我们有一个新的对象,即IDisposable接口,它用于控制非托管资源,这意味着我们可以释放那些无法控制内存释放的对象。在我们的例子中,我们将设置一个通过可观察序列(Subject)接收事件的订阅。
让我们更仔细地看看这个技术:
public void OnAppear()
{
_subscriptions = _geolocator.Positions.Subscribe (x =>
{
_currentPosition = x;
LocationUpdates.OnNext(x);
});
}
public void OnDisppear()
{
geolocator.Stop ();
if (subscriptions != null)
{
subscriptions.Dispose ();
}
}
我们将使用这些函数在MapPage出现和消失时被调用。OnAppear函数将创建对Subject的订阅,所以每当新的位置被推送到可观察序列时,我们将在我们订阅的另一侧收到一个项目。在这种情况下,我们将对另一个主题调用OnNext函数,这意味着我们将可观察序列的项目传递到另一个可观察序列中。
这是一个没有意义的函数。我们很快就会向你展示原因。
我们还将订阅分配给我们的IDisposable。订阅是一个非托管资源,这意味着如果没有使用IDisposable,我们无法控制订阅的释放。
为什么我们需要担心释放订阅?
有时我们的可观察流可能会将事件传播到主 UI 线程上的用户界面。如果我们更改页面,并且前一个页面的视图模型仍在接收事件以更新前一个页面的界面,这意味着事件将在主 UI 线程之外的另一个线程上更改用户界面,这将破坏应用程序。这只是其中一个例子,但当我们不再使用订阅时清理订阅是一个良好的实践,以控制不受欢迎的应用程序处理。
接下来是public属性:
#region Public Properties
public string Address
{
get
{
return address;
}
set
{
if (value.Equals(address))
{
return;
}
address = value;
OnPropertyChanged("Address");
}
}
#endregion
我们需要的只是一个字符串,它将被绑定到地图项下的 MapPageLabel。它将用于显示当前位置的地址。现在我们必须在 MapPage 上创建一个标签:
<Label x:Name="AddressLabel" Text="{Binding Address}" Grid.Row="1" Grid.Column="0"/>
我们下一步是利用我们从 CLLocationManager 收到的纬度和经度值。我们将使用 Geocoder 类从我们的位置获取地址信息。Geocoder 类用于将位置(纬度和经度)转换为地址信息。我们实际上可以在本地端进行此转换,但这个练习的目的是向您展示 Xamarin.Forms 中可用于在不同平台之间共享的内容。
现在让我们回到回答关于在两个可观察序列之间传递事件的问题。
让我们开始构建 MapPage.xaml.cs 文件:
private MapPageViewModel viewModel;
private IDisposable locationUpdateSubscriptions;
private IDisposable closestSubscriptions;
private Geocoder geocoder;
public MapPage ()
{
InitializeComponent ();
}
public MapPage (MapPageViewModel model)
{
viewModel = model;
BindingContext = model;
InitializeComponent ();
Appearing += handleAppearing;
Disappearing += handleDisappearing;
geocoder = new Geocoder ();
}
在这里,我们创建了另外两个 IDisposables 来处理来自视图模型的事件。我们还将订阅和处置页面的出现和消失事件,所以现在添加 HandleAppearing 和 HandleDisappearing 函数:
private void HandleDisappearing (object sender, EventArgs e)
{
viewModel.OnDisppear ();
if (locationUpdateSubscriptions != null)
{
locationUpdateSubscriptions.Dispose ();
}
if (closestSubscriptions != null)
{
closestSubscriptions.Dispose ();
}
}
private void HandleAppearing (object sender, EventArgs e)
{
viewModel.OnAppear ();
locationUpdateSubscriptions = viewModel.LocationUpdates.Subscribe (LocationChanged);
}
我们还创建了一个新的 Geocoder,所以每次我们从视图模型中的可观察序列接收到事件时,我们都会使用这个位置通过以下函数从 Geocoder 中检索地址信息:
private void LocationChanged (IPosition position)
{
try
{
var formsPosition = new Xamarin.Forms.Maps.Position(position.Latitude, position.Longitude);
geocoder.GetAddressesForPositionAsync(formsPosition)
.ContinueWith(_ =>
{
var mostRecent = _.Result.FirstOrDefault();
if (mostRecent != null)
{
viewModel.Address = mostRecent;
}
})
.ConfigureAwait(false);
}
catch (Exception e)
{
System.Diagnostics.Debug.WriteLine ("MapPage: Error with moving map region - " + e);
}
}
这是我们需要用来检索我们的纬度和经度位置以及更新当前地址的所有内容。我们 iOS 版本的最后一步是在地图上更新位置;我们希望地图视图放大到我们的当前位置,并将蓝色标记放置在地图上。接下来,我们在 LocationChanged 函数的末尾添加以下内容:
MapView.MoveToRegion (MapSpan.FromCenterAndRadius (formsPosition, Distance.FromMiles (0.3)));
MoveToRegion 函数需要一个 MapSpan;MapSpan 是从纬度、经度点和从位置点开始的半径创建的。从点绘制一个圆,以给出在地图上显示的视图半径;在我们的情况下,半径是纬度和经度位置周围的 0.3 英里。
ContinueWith 函数用于在任务完成时执行一些额外的工作。一旦我们检索到所有可能的地址名称,我们就唤醒列表中的第一个,并将其分配给变量的 Address 属性。
我们最后一步是完成项目的其余部分;我们首先必须为地理定位器类创建一个 iOS 模块:
public class IOSModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<GeolocatorIOS>().As<IGeolocator>().SingleInstance();
}
}
然后最后,我们将额外的代码添加到 AppDelegate.cs 文件中(与之前的示例 iOS 项目完全相同):
[Register ("AppDelegate")]
public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
{
public override bool FinishedLaunching (UIApplication app, NSDictionary options)
{
global::Xamarin.Forms.Forms.Init (this, bundle);
global::Xamarin.FormsMaps.Init (this, bundle);
initIoC ();
LoadApplication (new App ());
return base.FinishedLaunching (app, options);
}
private void initIoC()
{
IoC.CreateContainer ();
IoC.RegisterModule (new IOSModule());
IoC.RegisterModule (new XamFormsModule());
IoC.RegisterModule (new PortableModule());
IoC.StartContainer ();
}
}
太棒了!让我们运行项目并点击 查找位置 按钮。观察地图如何更新,并显示在前面标签中显示的地址。
让我们继续到 Android 项目并实现相同的功能。
Android 和 LocationManager
Android LocationManager 的工作方式类似于 CLLocationManager,但我们将使用可观察序列来处理位置更新。当接收到位置更新时,会实例化一个新的 Position 对象,其中包含位置更新的纬度和经度值。然后,得到的 Position 被推送到 Geolocator 的 Subject。
首先,我们创建 Geolocator 实现。它也必须继承 ILocationListener 接口:
public class GeolocatorDroid : IGeolocator, ILocationListener
{
private string provider = string.Empty;
public Subject<IPosition> Positions { get; set; }
#region ILocationListener implementation
public void OnLocationChanged (Location location)
{
Positions.OnNext (new Position ()
{
Latitude = location.Latitude,
Longitude = location.Longitude
});
}
public void OnProviderDisabled (string provider)
{
Console.WriteLine (provider + " disabled by user");
}
public void OnProviderEnabled (string provider)
{
Console.WriteLine (provider + " disabled by user");
}
public void OnStatusChanged (string provider, Availability status, Bundle extras)
{
Console.WriteLine (provider + " disabled by user");
}
#endregion
}
提示
你可能已经注意到了 #define 关键字。这些关键字对于分隔不同的部分和在代码表中引用位置非常有用,使代码更易于阅读。
我们唯一关心的是 OnLocationChanged 函数;每当位置管理器接收到位置更新时,监听器函数将被调用,并带有纬度和经度值,然后我们将使用这些值将它们推入 Geocoder 和 MapSpan 的可观察序列。
我们还必须实现 ILocationListener 接口的额外要求。由于该接口继承了 IJavaObject 接口,我们被要求实现 Dispose 函数和 IntPtr 对象。
为了节省时间,我们可以让类继承 Java.Lang.Object 类,如下所示:
public class GeolocatorDroid : Object, IGeolocator, ILocationListener
接下来,我们添加构造函数:
private LocationManager locationManager;
public GeolocatorDroid()
{
Positions = new Subject<IPosition> ();
locationManager = (LocationManager)Application.Context.GetSystemService(Context.LocationService);
provider = LocationManager.NetworkProvider;
}
在构造函数中,我们使用 GetSystemService 函数获取所需的位置服务系统服务。下面的行简单地检索 LocationManager 的 NetworkProvider;我们需要使用这个来启动位置更新。我们可以设置进一步的配置来获取正确的提供者(主要是日志记录目的),但在本例中我们不会太在意,因为我们只对检索位置位置感兴趣。
现在,是时候实现 IGeolocator 接口的其他所需函数了:
public void Start()
{
if (locationManager.IsProviderEnabled(provider))
{
locationManager.RequestLocationUpdates (provider, 2000, 1, this);
}
else
{
Console.WriteLine(provider + " is not available. Does the device have location services enabled?");
}
}
public void Stop()
{
locationManager.RemoveUpdates (this);
}
Start 函数首先会检查我们是否已启用这些服务,然后通过调用 RequestLocationUpdates 函数,我们传入提供者、位置更新的最小时间间隔、更新之间的最小位置距离以及每个位置更新时需要调用的挂起意图;在我们的案例中,这是地理定位器(与启动位置更新的相同类),因为我们实现了 ILocationListener 类。
Stop 函数简单地从 Geolocator 中移除更新,这反过来会停止来自位置管理器的位置更新。我们在实现 Android Geolocator 的下一步是创建 Android IoC 模块,并在 IoC 容器中注册此实现:
public void Register(ContainerBuilder builer)
{
builer.RegisterType<GeolocatorDroid>().As<IGeolocator>().SingleInstance();
}
我们最后的步骤是设置 MainActivity 类,这与之前的工程完全相同:
[Activity (Label = "Locator.Droid", Icon = "@drawable/icon", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsApplicationActivity
{
protected override void OnCreate (Bundle bundle)
{
base.OnCreate (bundle);
global::Xamarin.Forms.Forms.Init (this, bundle);
global::Xamarin.FormsMaps.Init (this, bundle);
LoadApplication (new App ());
}
private void initIoC()
{
IoC.CreateContainer ();
IoC.RegisterModule (new DroidModule());
IoC.RegisterModule (new XamFormsModule());
IoC.RegisterModule (new PortableModule());
IoC.StartContainer ();
}
}
提示
注意我们开始从以前的项目中重用多少代码。为什么要在可以节省大量时间的情况下重新发明轮子,从其他项目中已经解决的问题中抽取相似的问题呢?
Android 项目的最后一步是为应用使用位置服务申请一些 Android 权限。打开 Mainfest.xml 并添加以下内容:
<application android:label="Locator">
<meta-data android:name="com.google.android.maps.v2.API_KEY" android:value="YOUR-API-KEY" />
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version" />
</application>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
在 <application> 标签内部,我们必须放置 API_KEY,这是从 Google API 平台生成的(我们将在稍后进行此操作)。然后我们必须为 LocationManager 工作添加 ACCESS_FINE_LOCATION、ACCESS_COARSE_LOCATION 和 ACCESS_NETWORK_STATE 权限。我们可以通过 Application 窗口切换这些权限:

创建退出点
您可能已经注意到启动页上添加了额外的退出应用程序的按钮。我们将创建一个抽象的退出应用程序对象。首先创建一个名为 Extras 的新文件夹,然后为 IMethods 接口创建一个新文件:
public interface IMethods
{
void Exit();
}
小贴士
在继续教程之前,尝试自己实现每个项目的本地端。
让我们从 iOS 版本开始:
public class IOSMethods
{
public void Exit()
{
UIApplication.SharedApplication.PerformSelector(new ObjCRuntime.Selector("terminateWithSuccess"), null, 0f);
}
}
对于 iOS 版本,我们必须深入到 SharedApplication 对象中并执行一个选择器方法 terminateWithSuccess。然后我们必须在我们的 iOS 模块中注册这个新对象:
public void Register(ContainerBuilder builer)
{
builer.RegisterType<GeolocatorIOS>().As<IGeolocator>().SingleInstance();
builer.RegisterType<IOSMethods>().As<IMethods>().SingleInstance();
}
现在是 Android 实现步骤:
public class DroidMethods
{
public void Exit()
{
Android.OS.Process.KillProcess(Android.OS.Process.MyPid());
}
}
使用 Android 操作系统命名空间,我们使用静态项 Process 调用主进程上的 KillProcess 函数。同样,我们也在 IoC 容器中注册了这个函数:
public void Register(ContainerBuilder builer)
{
builer.RegisterType<GeolocatorDroid>().As<IGeolocator>().SingleInstance();
builer.RegisterType<DroidMethods>().As<IMethods>().SingleInstance();
}
最后,我们在 MainPageViewModel 中使用 IMethods 接口调用退出函数:
public MainPageViewModel (INavigationService navigation, Func<Action, ICommand> commandFactory,
IMethods methods) : base (navigation)
{
exitCommand = commandFactory (() => methods.Exit());
locationCommand = commandFactory (() => Navigation.Navigate(PageNames.MapPage));
}
仔细观察,我们使用命令工厂将退出命令初始化为一个新的 Xamarin.Forms Command,当这个命令被执行时,它将调用 IMethods 接口中的 Exit 方法。
我们的最后一步是使用 Google API 为我们的 Android 版本创建一个 API 密钥。
为 Android 创建 API 密钥
为了我们能够创建 API 密钥,我们必须访问 Google API 门户。在配置 Google Maps 时,Android 需要执行这个额外的步骤:
小贴士
您需要一个 Google 开发者账户来完成本节。
-
访问以下链接以在 API 门户中创建一个新项目:
console.developers.google.com/iam-admin/projects.![为 Android 创建 API 密钥]()
-
从顶部菜单选择 创建项目 并将项目命名为
Locator:![为 Android 创建 API 密钥]()
小贴士
有关设置 API 密钥的更多信息,请访问此链接:
developers.google.com/maps/documentation/javascript/get-api-key#get-an-api-key. -
一旦我们有了新的项目,访问 API 管理器并选择 Google Maps Android API:
![为 Android 创建 API 密钥]()
-
选择 启用 按钮,然后点击左侧菜单中的 凭据。我们想要从下拉列表中创建一个新的 API 密钥:
![为 Android 创建 API 密钥]()
-
确保我们选择一个 Android 密钥:
![为 Android 创建 API 密钥]()
-
我们将保留名称为
Android key 1。现在点击 创建 按钮:![为 Android 创建 API 密钥]()
-
最后,让我们选择我们的 Android 密钥并将其放置在
AndroidManifest.xml文件中,该文件中声明了YOUR-API-KEY:![为 Android 创建 API 密钥]()
恭喜,我们现在已经将 iOS 和 Android 定位服务与 Google Maps 集成。
现在,让我们继续到 Windows Phone 版本。
创建我们的 Windows 项目
再次进入 Visual Studio,我们首先创建一个新的 c-shape 通用的 Windows 项目,并将其命名为 Locator.WinRT:

我们可以移除 Windows 商店和共享项目。在移除共享项目之前,将 app.xaml 文件移动到 Windows Phone 项目中。
提示
Xamarin.Forms.Maps 中的 Map 对象在 Windows Phone 8.1 中不可用。我们必须使用通用平台。
对于我们的 Windows Phone 版本,我们需要以下内容:
-
用于注册定位器和接口方法的 Windows Phone 模块
-
实现定位器接口
-
实现方法接口
注意
想想看...
这就是我们要做的,以复制 Windows Phone 应用程序吗? 想想如果我们完全从零开始在 Windows 平台上重建这个应用程序,会有多少额外的工作要做。
接下来,添加三个文件夹,Modules、Location 和 Extras,并为每个文件夹创建一个新的 .cs 文件,并相应地命名它们:WinPhoneModule.cs、GeolocatorWinPhone.cs 和 WinPhoneMethods.cs。
首先,我们必须更改 PCL 项目的目标,使其与 Windows Phone 框架兼容。选择两个 PCL 项目的 Windows Phone 8.1 目标,然后 Windows 项目可以引用这两个 PCL 项目:

我们还必须导入 Xamarin.Forms、Xamarin.Forms.Maps 和 Autofacnuget 包。
Windows Phone 的核心定位服务
现在是激动人心的部分。让我们集成核心定位服务。首先,我们必须打开某些权限。打开 package.appmanifest 文件,选择 功能 选项卡,并选择 位置 复选框:

其次,打开 GeolocatorWinPhone.cs 文件,让我们开始构建 Windows Phone 定位器类。
让我们先从创建构造函数开始:
public class GeolocatorWinPhone : IGeolocator
{
public Subject<IPosition> Positions { get; set; }
Geolocator _geolocator;
public GeolocatorWinPhone()
{
Positions = new Subject<IPosition>();
geolocator = new Geolocator();
_geolocator.DesiredAccuracyInMeters = 50;
}
}
我们正在从IGeolocator接口实现一个本地的Geolocator,这意味着我们需要为位置创建一个可观察的序列。我们还需要一个Geolocator对象来接收位置更新,我们将使用它将事件推送到序列中。对于所有本地的定位器,我们可以为位置点设置精度,这正是我们在以下行中所做的:
geolocator.DesiredAccuracyInMeters = 50;
我们的下一步是实现Start和Stop函数:
public async void Start()
{
try
{
var geoposition = await _geolocator.GetGeopositionAsync(
maximumAge: TimeSpan.FromMinutes(5),
timeout: TimeSpan.FromSeconds(10)
);
_geolocator.PositionChanged += geolocatorPositionChanged;
// push a new position into the sequence
Positions.OnNext(new Position()
{
Latitude = geoposition.Coordinate.Latitude,
Longitude = geoposition.Coordinate.Longitude
});
}
catch (Exception ex)
{
Console.WriteLine("Error retrieving geoposition - " + ex);
}
}
Start函数使用Geolocator通过异步函数GetGeopositionAsync检索位置,该函数将位置的最大年龄作为参数,这意味着一旦时间周期过去,位置将再次更新。当位置更新期间达到超时值时,对这个位置的请求将被取消。我们还在以下函数中监听事件处理程序PositionChanged:
private void GeolocatorPositionChanged(Geolocator sender, PositionChangedEventArgs args)
{
// push a new position into the sequence
Positions.OnNext(new Position ()
{
Latitude = args.Position.Coordinate.Latitude,
Longitude = args.Position.geoposition.Coordinate.Longitude
});
}
实际上我们有两个地方,它们会将新的地理位置的纬度和经度推送到可观察的序列中。
现在我们添加Stop函数:
public void Stop()
{
// remove event handler
_geolocator.PositionChanged -= GeolocatorPositionChanged;
}
这所做的只是移除了我们在Start函数中分配的事件处理函数。
注意
你应该在这个项目中注意到发展模式,比如我们如何实现抽象接口、生成模块、注册类型等等。这些流程在所有平台上都是一样的。
这就是Geolocator类的全部内容;我们现在可以继续到WinPhoneModule:
public class WinPhoneModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<GeolocatorWinPhone>().As<IGeolocator>().SingleInstance();
builer.RegisterType<WinPhoneMethods>().As< IMethods>().SingleInstance();
}
}
现在让我们来看看WinPhoneMethods类。我们只需要实现一个函数,Exit。
应用程序类
静态类Application在 iOS 的UIApplication类中扮演着类似的角色。我们只是引用当前的应用程序,并终止:
public class WinPhoneMethods : IMethods
{
public void Exit()
{
Application.Current.Terminate();
}
}
现在我们只需用MainPage.xaml页面构建剩余的元素:
<forms:WindowsPhonePage
x:Class="Locator.WinPhone.MainPage"
mc:Ignorable="d"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
</forms:WindowsPhonePage>
我们也在MainPage.xaml.cs文件中这样做:
public MainPage()
{
InitializeComponent();
InitIoC();
NavigationCacheMode = NavigationCacheMode.Required;
LoadApplication(new Locator.App());
}
private void InitIoC()
{
IoC.CreateContainer();
IoC.RegisterModule(new WinPhoneModule());
IoC.RegisterModule(new SharedModule(true));
IoC.RegisterModule(new XamFormsModule());
IoC.RegisterModule(new PortableModule());
IoC.StartContainer();
}
与上一章完全相同,我们正在启动IoC容器,添加我们的模块,并加载Xamarin.Forms.App对象。唯一的区别是SharedModule,因为我们传入 true 所以使用NativeMessageHandler。
最后,我们还有一个问题要解决。自从Xamarin.Forms 1.5 以来,只有 Windows Phone Silverlight 支持使用 Google Maps。我们必须添加一个额外的库来在 Windows Phone 8.1 中使用地图。
注意
感谢Peter Foot解决这个问题。
幸运的是,有一个开源库可以解决这个问题。我们必须安装 nuget 包InTheHand.Forms.Maps。
提示
这个库仅适用于Xamarin.Forms 2.1.0.6529,这意味着整个示例必须坚持这个版本的Xamarin.Forms。
然后,在App.xaml.cs内部,我们需要初始化Xamarin.Forms和Xamarin.Forms.Maps。Xamarin.Forms.Maps框架通过InTheHand.Forms.Maps库这样初始化:
if (rootFrame == null)
{
rootFrame = new Frame();
rootFrame.CacheSize = 1;
if (e.PreviousExecutionState == ApplicationExecutionState.Terminated)
{
}
Xamarin.Forms.Forms.Init(e);
InTheHand.FormsMaps.Init("YOUR-API-KEY");
Window.Current.Content = rootFrame;
}
就像那样,我们现在已经在 Windows Phone 上有了应用程序。现在我们已经有了运行在 Google Maps 上的核心位置服务,让我们通过 Google API 平台更进一步。
网络服务和数据合约
我们现在将探讨创建一个网络服务控制器来访问 Google 提供的网络服务。这些是实现下载 JSON 数据、反序列化它并将这些数据馈送到可观察序列以进行处理的有用实现。有了网络服务控制器,我们可以使用更多的IObservable接口。这些序列将用于从网络源接收反序列化的 JSON 对象,并将这些对象馈送到我们的视图模型中。
我们将把我们的网络服务控制器保存在Locator.Portable项目中。记住,我们可以跨不同平台共享这项工作,因为所有平台都使用某种形式的 HTTP 客户端来连接到 Web URL。
关于数据合约?
您的数据合约是一个 JSON 对象,用于吸收反序列化对象的元素,所以每次我们拉取原始 JSON 数据时,您的合约将是反序列化的对象或对象。
那么,下一个问题是,我们要从我们的应用程序中拉取哪些数据?
我们将使用 Google 的Geocoder API 将地址信息转换为纬度和经度位置。我们将拉取一个地址列表,计算它们的纬度和经度位置,计算离我们当前位置最近的地址,并在地图上放置一个标记。
我们的第一步是在Locator.Portable中创建一个名为WebServices的新文件夹。在这个文件夹内部,我们想要创建一个名为GeocodingWebServiceController的新文件夹,并在其中创建一个名为Contracts的文件夹。让我们首先实现我们的合约。实现 JSON 对象的一个简单快捷的方法是使用像这样的在线应用程序:json2csharp.com/。
当我们拉取 JSON 数据时,需要花费时间来查找文本中所有必需的属性。这提供了一种很好的方式,即调用网络服务 URL,检索一些示例 JSON 数据,并将这些 JSON 数据粘贴到这里的框中:

注意
感谢乔纳森·基思为我们节省了时间。
此应用程序根据您输入的 JSON 数据创建 c-sharp JSON 对象。现在让我们将我们的示例 JSON 数据粘贴到框中,但在我们能够这样做之前,我们必须访问Google API。
为地理编码创建另一个 API 密钥
重新登录到 Google 开发者控制台,我们的第一步是从 API 管理器中启用地理编码 API:

我们然后选择我们之前创建的Locator项目,这次我们将创建一个浏览器密钥,通过 HTTP 请求访问地理编码 API:

将密钥命名为“地理编码密钥”并点击创建。我们现在将使用这个密钥来处理传递给地理编码 API 的每个 HTTP 请求:

创建 GeocodingWebServiceController
创建GeocodingWebServiceController的第一步是使用您的 API 密钥访问网络 URL 以拉取一些示例 JSON 数据;这里是一个测试链接:https://maps.googleapis.com/maps/api/geocode/json?address=1600+Amphitheatre+Parkway,+Mountain+View,+CA&key=YOUR_API_KEY.
在YOUR_API_KEY处,将此文本替换为您的全新创建的 API 密钥,然后将此链接粘贴到浏览器中。你应该会得到如下 JSON 结果:
{
"results" : [
{
"address_components" : [
{
"long_name" : "1600",
"short_name" : "1600",
"types" : [ "street_number" ]
},
{
"long_name" : "Amphitheatre Parkway",
"short_name" : "Amphitheatre Pkwy",
"types" : [ "route" ]
},
{
"long_name" : "Mountain View",
"short_name" : "Mountain View",
"types" : [ "locality", "political" ]
},
{
"long_name" : "Santa Clara County",
"short_name" : "Santa Clara County",
"types" : [ "administrative_area_level_2", "political" ]
},
我们将复制并粘贴整个生成的 JSON 到Json2Sharp中,以创建我们的 c-sharp 对象:

由于有很多 JSON 对象,所以在Contracts文件夹中,创建以下文件:
-
AddressComponentContract.cs
-
GeocodingContract.cs
-
GeocodingResultContract.cs
-
GeometryContract.cs
-
LocationContract.cs
-
NortheastContract.cs
-
SouthwestContract.cs
-
ViewportContract.cs
让我们从AddressComponentContract.cs开始:
public sealed class AddressComponentContract
{
#region Public Properties
public string long_name { get; set; }
public string short_name { get; set; }
public List<string> types { get; set; }
#endregion
}
确保我们将所有这些合约放在Locator.Portable.GeocodingWebServiceController.Contracts命名空间中。
备注
命名空间应该按照文件夹层次结构命名。
现在让我们来实现GeocodingContract:
public sealed class GeocodingContract
{
#region Public Properties
public List<GeocodingResultContract> results { get; set; }
public string status { get; set; }
#endregion
}
其余的文件完全相同;我们只是复制由Json2Sharp创建的 c-sharp 对象。现在是我们完成其他任务的时候了:
public sealed class GeocodingResultContract
{
#region Public Properties
public List<AddressComponentContract> address_components { get; set; }
public string formatted_address { get; set; }
public GeometryContract geometry { get; set; }
public string place_id { get; set; }
public List<string> types { get; set; }
#endregion
}
确保你双检查属性名称与 JSON 属性完全相同,否则 JSON 字符串中的值将无法正确反序列化。
备注
我们不会粘贴每个合约,因为这应该足够指导你构建其他的。
现在我们有了地理编码合约,让我们为GeocodingWebServiceController创建接口:
public interface IGeocodingWebServiceController
{
#region Methods and Operators
IObservable<GeocodingContract> GetGeocodeFromAddressAsync (string address, string city, string state);
#endregion
}
这是一个非常小的接口;我们只有一个函数,GetGeocodeFromAddressAsync。该函数需要三个参数来构建 Web URL 中的参数。
现在让我们来实现这个接口。
小贴士
在面向对象和抽象编码中,一个好的做法是在实现与接口相对应的类之前声明接口;这将帮助你更快地构建类。
Newtonsoft.Json 和 Microsoft HTTP 客户端库
由于我们将要反序列化 JSON,我们需要导入一个 JSON 框架库。Newtonsoft 是最常用的框架之一,所以让我们将这个库导入到我们的Locator.Portable项目中:

我们还需要导入 HTTP 客户端库,以便我们的 Web 服务控制器可以访问在线 Web 服务:

现在我们已经为我们的Locator.Portable项目添加了所有额外的库,在我们实现IGeocodingWebServiceController之前,我们必须对项目结构做一些补充:

右键单击Locator并创建一个名为Locator.Shared的新共享项目:

ModernHttpClient 和客户端信息处理器
在这个项目中,我们将创建一个共享模块来在 IoC 容器中注册 HttpClientHandler 类。HttpClientHandler 是一个消息处理器类,它接收 HTTP 请求并返回 HTTP 响应。消息处理器在客户端和服务器端都用于处理/委派不同端点之间的请求。
在我们的示例中,我们关注客户端,因为我们正在调用服务器;我们的客户端处理器将用于处理来自 HTTP 客户端的 HTTP 消息。
让我们从添加 ModernHttpClient 库到我们的 Locator(我们将把这个项目称为 Xamarin.Forms 项目)以及所有原生项目开始:

我们还希望将 Microsoft 客户端库包添加到所有原生项目中。
在我们的共享项目中,请记住我们无法导入库;这些项目仅用于共享代码。在这个项目中,我们想要创建一个名为 Modules 的文件夹。在 Modules 文件夹中,创建一个名为 SharedModule.cs 的新文件,并实现以下内容:
public sealed class SharedModule : IModule
{
#region Fields
private bool isWindows;
#endregion
#region Constructors and Destructors
public SharedModule(bool isWindows)
{
isWindows = isWindows;
}
#endregion
#region Public Methods and Operators
public void Register(ContainerBuilder builder)
{
HttpClientHandler clientHandler = isWindows ? new HttpClientHandler() : new NativeMessageHandler();
clientHandler.UseCookies = false;
clientHandler.AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip;
builder.Register(cb => clientHandler).As<HttpClientHandler>().SingleInstance();
}
#endregion
}
有一个需要注意的小变化,那就是在 iOS 和 Android 项目以及 Windows Phone 项目之间。Windows 必须在 IoC 容器中的 HttpClientHandler 上使用 NativeMessageHandler。在 iOS 和 Android 中,我们可以使用默认的 HttpClientHandler。
我们告诉客户端处理器我们不会使用 cookies,并允许客户端处理器自动解压缩通过客户端处理器拉取的数据(GZIP 是一种常见的 JSON 数据压缩形式)。
现在,让我们将注意力集中在构造函数上。我们只需传入一个 bool 来确定我们是否正在使用 Windows 来注册适用于当前平台的正确类型的信息处理器。
现在,让我们将此模块添加到 AppDelegate 和 MainActivity 文件中的注册中;它必须在 LoadApplication 函数之前调用:
private void InitIoC()
{
IoC.CreateContainer ();
IoC.RegisterModule (new IOSModule());
IoC.RegisterModule (new SharedModule(false));
IoC.RegisterModule (new XamFormsModule());
IoC.RegisterModule (new PortableModule());
IoC.StartContainer ();
}
太棒了!我们现在可以在 IoC 容器中访问我们的 HTTP 客户端处理器,所以让我们开始构建 GeocodingWebServiceController 类:
public sealed class GeocodingWebServiceController : IGeocodingWebServiceController
{
#region Fields
/// <summary>
/// The client handler.
/// </summary>
private readonly HttpClientHandler clientHandler;
#endregion
#region Constructors and Destructors
public GeocodingWebServiceController (HttpClientHandler clientHandler)
{
clientHandler = clientHandler;
}
#endregion
}
将 JSON 数据喂入 IObservable 框架
由于我们将在 IoC 容器中注册此 Web 服务控制器,我们可以从 SharedModule 类中提取并注册我们刚刚创建的客户端处理器。现在我们必须实现我们在接口中定义的函数:
#region Public Methods
public IObservable<GeocodingContract> GetGeocodeFromAddressAsync(string address, string city, string state)
{
var authClient = new HttpClient(_clientHandler);
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(string.Format(ApiConfig.GoogleMapsUrl, address, city, state)));
return Observable.FromAsync(() => authClient.SendAsync(message, new CancellationToken(false)))
.SelectMany(async response =>
{
if (response.StatusCode != HttpStatusCode.OK)
{
throw new Exception("Respone error");
}
return await response.Content.ReadAsStringAsync();
})
.Select(json => JsonConvert.DeserializeObject<GeocodingContract>(json));
}
#endregion
起初可能看起来有些令人畏惧,但让我们将其分解。我们的 Web 服务控制器将下载数据,将数据反序列化到我们的主要 JSON 对象 GeocodingContract 中,并在可观察序列中创建合约。
当我们实例化一个新的 HttpClient 时,我们必须传递我们注册的客户端处理程序来委派从 HTTP 客户端发送的请求消息。然后我们创建一个新的 Http.Get 消息;这将通过 HttpClient 发送并通过消息处理程序(HttpClientHandler)委派,然后消息处理程序将接收 JSON 响应。
这就是事情变得复杂的地方。看看 Observable.FromAsync 函数;这个方法接受一个异步函数,将运行并等待该函数,并将数据作为可观察序列返回。异步函数必须返回 IObservable。
我们传递的函数是 HttpClient 的 SendAsync 函数;然后我们使用 RX 函数 SelectMany 来获取所有响应对象。如果每个响应对象都带有 HTTP 状态码 200 (OK),我们就将响应内容作为字符串返回。注意表达式前面的 async 关键字;我们必须使用异步函数来等待 ReadAsAsync 函数并返回响应内容作为 JSON 字符串。
最后,我们使用 RX 函数 Select 来获取每个响应字符串并返回反序列化的 GeocodingContract。此合约将被输入到可观察序列中,并返回给原始调用者 Observable.FromAsync,然后它将作为函数返回的数据。
更多响应式扩展
在我们继续之前,让我们更多地讨论我们刚刚使用的 RX 函数。Select 函数用于迭代任何 List、Enumerable 或 IObservable,并将每个项的值取出来创建一个新的可观察序列。
假设我们有一个具有字符串属性 Name 的对象列表,我们执行以下操作:
var newObservable = list.Select (x => x);
我们只是返回相同的项序列,但然后我们做类似这样的事情:
var newObservable = list.Select (x => x.Name);
我们的新序列将只包含每个对象的 Name 属性。这些函数对于过滤流和列表非常有用。
资源 (RESX) 文件
注意在我们的 GetGeocodeFromAddressAsync 函数中,我们引用了一个静态类,ApiConfig:
ApiConfig.GoogleMapsUrl
这是一个包含应用程序资源的技术,例如字符串、URL、常量变量、设置属性等。它还用于具有基于语言设置的不同的常量变量值的语言。通常,你会这样让你的应用支持多语言。
让我们在 Locator.Portable 项目中创建一个名为 Resources 的新文件夹:

在 ApiConfig.Designer.cs 文件中,我们必须根据文件夹层次结构设置命名空间。在这个例子中,它是 Locator.Portable | Resources。
小贴士
Locator.Portable 是我们程序集的名称。我们必须知道程序集名称,以便在构建应用程序时知道文件夹将存储在哪里。要查找您的程序集名称,请访问属性页面,如图所示。

现在我们有了ApiConfig.resx文件,让我们为GoogleMapsUrl属性添加一个变量;在ApiConfig.resx文件中粘贴以下内容:
<!-- url -->
<data name="GoogleMapsUrl" xml:space="preserve">
<value>https://maps.googleapis.com/maps/api/geocode/json?address={0},+{1},+{2}&key={YOUR-BROSWER-API-KEY}</value>
</data>
注意
当你保存此文件时,你会注意到ApiConfig.Designer.resx文件会自动生成,这意味着命名空间可能改变到错误的文件夹路径。有时我们必须手动更改文件夹路径,每次此文件重新生成时。
使用 GeocodingWebServiceController
现在我们已经设置了我们的 Web 服务控制器,让我们将其集成到我们的MapPageViewModel中。我们的第一步是在 IoC 容器中注册 Web 服务控制器;打开PortableModule.cs并在Register函数中添加以下内容:
builer.RegisterType<GeocodingWebServiceController> ().As<IGeocodingWebServiceController>().SingleInstance();
现在我们更新MapPageViewModel中的构造函数,以使用 IoC 容器中的GeocodingWebServiceController:
#region Constructors
public MapPageViewModel (INavigationService navigation, IGeolocator geolocator,
IGeocodingWebServiceController geocodingWebServiceController) : base (navigation)
{
_geolocator = geolocator;
_geocodingWebServiceController= geocodingWebServiceController;
LocationUpdates = new Subject<IPosition> ();
}
#endregion
我们的下一步是添加一个静态地址数组作为字典:
#region Constants
private IDictionary<int, string[]> addresses = new Dictionary<int, string[]>()
{
{0, new string[] { "120 Rosamond Rd", "Melbourne", "Victoria" }},
{1, new string[] { "367 George Street", "Sydney", "New South Wales" }},
{2, new string[] { "790 Hay St", "Perth", "Western Australi" }},
{3, new string[] { "77-90 Rundle Mall", "Adelaide", "South Australia" }},
{4, new string[] { "233 Queen Street", "Brisbane", "Queensland" }},
};
#endregion
我们将使用地理编码 API 来确定所有这些地址位置的纬度和经度位置,并从你的当前位置确定哪个更近。
OnNavigatedTo 和 OnShow
在我们进一步使用地理编码 API 之前,我们需要对导航设置做一些补充。让我们首先实现所有内容页的OnNavigatedTo函数。创建一个名为INavigableXamFormsPage.cs的新文件,并粘贴以下内容:
internal interface INavigableXamarinFormsPage
{
void OnNavigatedTo(IDictionary<string, object> navigationParameters);
}
注意
注意到internal关键字;这是因为这个类永远不会离开Xamarin.Forms项目。
现在我们想让每个页面都继承这个接口并创建OnNavigatedTo函数:
public partial class MainPage : ContentPage, INavigableXamarinFormsPage
{
public void OnNavigatedTo(IDictionary<string, object> navigationParameters)
{
}
}
public partial class MapPage : ContentPage, INavigableXamarinFormsPage
{
public void OnNavigatedTo(IDictionary<string, object> navigationParameters)
{
}
}
现在我们想在每次页面导航到时调用OnNavigatedTo函数。首先,让我们更新NavigationService的接口:
public interface INavigationService
{
Task Navigate (PageNames pageName, IDictionary<string, object> navigationParameters);
}
现在打开NavigationService类并更新Navigate函数:
#region INavigationService implementation
public async Task Navigate (PageNames pageName, IDictionary<string, object> navigationParameters)
{
var page = getPage (pageName);
if (page != null)
{
var navigablePage = page as INavigableXamarinFormsPage;
if (navigablePage != null)
{
await IoC.Resolve<NavigationPage> ().PushAsync (page);
navigablePage.OnNavigatedTo ();
}
}
}
#endregion
页面被推入后,我们随后调用OnNavigatedTo函数。
现在我们想在页面视图模型中也做类似的事情。在你的ViewModelBase类中添加以下内容:
public void OnShow(IDictionary<string, object> parameters)
{
LoadAsync(parameters).ToObservable().Subscribe(
result =>
{
// we can add things to do after we load the view model
},
ex =>
{
// we can handle any areas from the load async function
});
}
protected virtual async Task LoadAsync(IDictionary<string, object> parameters)
{
}
OnShow函数将接收来自对应页面的OnNavigatedTo函数的导航参数。
注意处理异步函数的 RX 方法,当LoadAsync完成后?
我们有处理LoadAsync函数结果和错误的选择。你可能也注意到了使用箭头的简短表达式。这种类型的语法被称为 lambda 表达式,这是一种非常常见的 C#语法,用于简化函数、参数和委托。我们的LoadAsync也是虚拟的,这意味着任何实现此接口的页面视图模型都可以重写此函数。
现在我们对Xamarin.Forms项目(Locator)做一些额外的补充。在UI文件夹中创建一个新文件,命名为XamarinNavigationExtensions.cs。现在进行实现:
public static class XamarinNavigationExtensions
{
#region Public Methods and Operators
// for ContentPage
public static void Show(this ContentPage page, IDictionary<string, object> parameters)
{
var target = page.BindingContext as ViewModelBase;
if (target != null)
{
target.OnShow(parameters);
}
}
#endregion
}
仔细观察,我们实际上是在为所有ContentPage类型创建扩展函数。ContentPage的OnShow函数将提取绑定上下文作为ViewModelBase并调用视图模型的OnShow函数,然后视图模型将调用LoadAsync。最后,我们对MapPage.xaml.cs和MainPage.xaml.cs进行更改:
public void OnNavigatedTo(IDictionary<string, object> navigationParameters)
{
this.Show (navigationParameters);
}
做得很好!我们刚刚实现的是一个 Windows Phone 原则。我们知道当OnNavigatedTo函数被调用时,我们的XAML布局已经相应地调整了大小。这个优势在于我们可以在该函数内部从页面中检索 x、y、高度和宽度数值。
毕达哥拉斯等角投影
现在回到地理编码 API。我们将实现计算纬度和经度(当前位置)最近地址背后的数学。
对于我们的第一步,我们需要为MapPageViewModel添加一些属性:
#region Private Properties
private IList<IPosition> _positions;
private Position _currentPosition;
private string _closestAddress;
private int _geocodesComplete = 0;
#endregion
现在对于额外的public属性,它将保存最近位置的字符串地址:
public string ClosestAddress
{
get
{
return _closestAddress;
}
set
{
if (value.Equals(_closestAddress))
{
return;
}
_closestAddress = value;
OnPropertyChanged("ClosestAddress");
}
}
现在我们必须为位置变化添加另一个Subject序列:
#region Subjects
public Subject<IPosition> ClosestUpdates { get; set; }
#endregion
这必须在构造函数中初始化:
ClosestUpdates = new Subject<IPosition> ();
现在是时候进入有趣的部分了。
我们该如何计算最近的位置?
让我们从第一个私有函数开始,它将从地址获取位置:
public async Task GetGeocodeFromAddress(string address, string city, string state)
{
var geoContract = await _geocodingWebServiceController.GetGeocodeFromAddressAsync(address, city, state);
if (geoContract != null && geoContract.results != null && geoContract.results.Count > 0)
{
var result = geoContract.results.FirstOrDefault();
if (result != null && result.geometry != null && result.geometry.location != null)
{
_geocodesComplete++;
_positions.Add(new Position()
{
Latitude = result.geometry.location.lat,
Longitude = result.geometry.location.lng,
Address = string.Format("{0}, {1}, {2}", address, city, state)
});
// once all geocodes are found, find the closest
if ((_geocodesComplete == _positions.Count) && _currentPosition != null)
{
FindNearestSite();
}
}
}
}
在这个函数中,我们终于可以使用我们的GeocodingWebServiceController了。
看看我们是如何传递将构成 Web 服务 URL 的变量的?
对于每个地址,我们必须 ping 这个 API 调用以获取计算最近位置所需的纬度和经度。然后我们对数据合同中的值进行一系列检查,以确保它们不是 null,直到我们得到GeometryContract值;然后我们将使用这些值创建一个新的位置并将其添加到列表中。
现在让我们对Position类和接口做一些小的修改:
public class Position : IPosition
{
public string Address {get; set;}
}
public interface IPosition
{
double Latitude {get; set;}
double Longitude {get; set;}
public string Address {get; set;}
}
添加Address属性,以便我们可以记录最近属性的地址字符串。我们需要在位置中记录这个信息,因为我们向 API 发送了如此多的请求,它们不一定按顺序完成,所以我们不能期望使用索引引用来获取列表中的位置索引,以与数组中的地址相对应。
现在让我们添加用于计算距离的数学函数,使用PythagorasEquirectangular投影。它使用角投影来计算地图平面上两个坐标之间的距离。我们还需要为PythagorasEquirectangular函数提供一个DegreesToRadians转换:
private double DegreesToRadians(double deg)
{
return deg * Math.PI / 180;
}
private double PythagorasEquirectangular
(double lat1, double lon1, double lat2, double lon2)
{
lat1 = DegreesToRadians(lat1);
lat2 = DegreesToRadians(lat2);
lon1 = DegreesToRadians(lon1);
lon2 = DegreesToRadians(lon2);
// within a 10km radius
var radius = 10;
var x = (lon2 - lon1) * Math.Cos((lat1 + lat2) / 2);
var y = (lat2 - lat1);
var distance = Math.Sqrt(x * x + y * y) * radius;
return distance;
}
如果距离超出了半径值,则不会使用。
小贴士
尝试调整这个设置,看看你得到的结果。
现在来看FindNearestSite函数:
private void FindNearestSite()
{
if (_geolocationUpdating)
{
_geolocationUpdating = false;
_geolocator.Stop();
GeolocationButtonTitle = "Start";
}
double mindif = 99999;
IPosition closest = null;
var closestIndex = 0;
var index = 0;
if (_currentPosition != null)
{
foreach (var position in _positions)
{
var difference = PythagorasEquirectangular(_currentPosition.Latitude, _currentPosition.Longitude,
position.Latitude, position.Longitude);
if (difference < mindif)
{
closest = position;
closestIndex = index;
mindif = difference;
}
index++;
}
if (closest != null)
{
var array = _addresses[closestIndex];
Address = string.Format("{0}, {1}, {2}", array[0], array[1], array[2]);
ClosestUpdates.OnNext(closest);
}
}
}
当所有地址的地理编码都已获取并添加到位置列表中时,我们将调用此功能。然后我们遍历所有位置,将每个位置与我们的当前位置进行比较,确定哪个坐标差值最小,并使用这个作为我们的最近位置。然后我们将一个新的位置推送到ClosestUpdates可观察序列,我们将在MapPage上订阅它。
在MapPageViewModel的最后一步是重写LoadAsync函数:
protected override async Task LoadAsync (IDictionary<string, object> parameters)
{
var index = 0;
for (int i = 0; i < 5; i++)
{
var array = _addresses [index];
index++;
GetGeocodeFromAddress(array[0], array[1], array[2]).ConfigureAwait(false);
}
}
这就是一切开始的地方;当页面加载时,它将遍历每个地址并下载地理编码,然后当我们计算完整个地址列表的计数后,我们找到最近的位置并将其推送到ClosestUpdates序列。我们还希望为每个地址并行运行GetGeocodeFromAddress函数;这就是为什么我们将ConfigureAwait设置为 false。
现在我们对MapPage做一些修改。我们现在将为MapPage使用两个IDisposables,一个用于视图模型中的每个主题:
private IDisposable _locationUpdateSubscriptions;
private IDisposable _closestSubscriptions;
现在我们更新了OnAppear和OnDisappear函数来处理对Subjects的订阅和释放:
private void HandleDisappearing (object sender, EventArgs e)
{
_viewModel.OnDisppear ();
if (_locationUpdateSubscriptions != null)
{
_locationUpdateSubscriptions.Dispose ();
}
if (_closestSubscriptions != null)
{
_closestSubscriptions.Dispose ();
}
}
private void HandleAppearing (object sender, EventArgs e)
{
_viewModel.OnAppear ();
_locationUpdateSubscriptions = _viewModel.LocationUpdates.Subscribe (LocationChanged);
_closestSubscriptions = _viewModel.ClosestUpdates.Subscribe (ClosestChanged);
}
我们最后的润色是添加一个每次对ClosetUpdates可观察序列进行调用的功能:
private void ClosestChanged (IPosition position)
{
try
{
var pin = new Pin()
{
Type = PinType.Place,
Position = new Xamarin.Forms.Maps.Position (position.Latitude, position.Longitude),
Label = "Closest Location",
Address = position.Address
};
MapView.Pins.Add(pin);
MapView.MoveToRegion(MapSpan.FromCenterAndRadius(new Xamarin.Forms.Maps.Position(position.Latitude, position.Longitude)
, Distance.FromMiles(0.3)));
}
catch (Exception e)
{
System.Diagnostics.Debug.WriteLine ("MapPage: Error with moving pin - " + e);
}
}
我们正在创建一个可以放置在地图上的标记。当我们点击标记时,这个标记也会显示地址信息。然后我们使用MoveToRegion函数将地图区域移动到显示这个标记的位置。
这就是全部内容;我们现在已经集成了 Google Maps 和 Geocoding。
摘要
在本章中,我们讨论了使用Xamarin.Forms和Xamarin.Forms.Maps进行开发。我们学习了如何在每个平台上实现位置服务,处理后台位置更新事件以及使用纬度和经度来计算位置。在所有三个平台上尝试运行应用程序,并观察位置更新和最近位置如何更新地图上的区域。在下一章中,我们将回到原生开发,并构建一个可以像音频播放器一样控制音频文件的应用程序。
第四章:构建音频播放器应用程序
在本章中,我们将回到原生 Xamarin。我们将使用 iOS 中的 AVFramework 和 AVAudioSessions、AVAudioSettings、AVAudioRecorder 对象来集成原生音频功能,以处理音频文件。在 Android 中,你将使用 Android.Media 库中的 MediaPlayer 对象。
预期知识:
-
对 iOS 的
AVAudioSessions、AVAudioSettings和AVAudioRecorder或 Android 的MediaPlayer和MediaRecorder类有一定的了解 -
NSLayoutConstraints
在本章中,你将学习以下内容:
-
项目设置
-
使用 MVVMCross 的控制反转
-
使用 Xamarin 本地创建视图模型
-
创建绑定
-
NSLayoutContraints
-
在便携式类库中设置 MVVMCross
-
设置 iOS 上的 MVVMCross
-
设置 Android 上的 MVVMCross
-
SoundHandler接口 -
使用
AVAudioPlayer框架实现 iOS 的SoundHandler -
Mvx IoC 容器
-
音频播放器
-
NSLayout的更简洁的代码方法 -
创建
AudioPlayerPageViewModel -
使用
MediaPlayer框架实现 Android 的SoundHandler -
XML 和 Mvx 绑定
解决方案设置
既然我们回到了 Xamarin 本地,是时候让你的思维从 XAML 转回到本地的 iOS 和 Android 了。我们不会在用户界面设计上花费太多时间,而是更多地关注使用原生框架进行音频处理。
小贴士
如果你在这台电脑上测试这个应用程序,麦克风仍然会工作,因为它将使用你的笔记本电脑的麦克风。
既然我们已经探讨了跨平台应用程序和代码共享,我们将应用一些这些原则到原生开发中,并设置一个 MVVM 架构。让我们从设置三个不同的项目开始,一个 iOS、Android 和 PCL 项目:

使用 MVVMCross 的控制反转
在最后两章中,我们探讨了 IoC 容器和引导基础;现在,是时候使用不同的库来用 Xamarin 本地实现这个原则了。
对于所有项目,我们希望导入 MVVMCross 库:

小贴士
MVVMCross 可用于 Xamarin.Forms、Xamarin.iOS、Xamarin.Android、Xamarin.Mac 和 Windows,所以请选择。
MVVMCross 的设置与 AutoFac 非常不同,但原则是相同的。
使用 Xamarin 本地创建视图模型
在我们添加库之后,让我们从 AudioPlayer.Portable 项目开始。创建一个名为 ViewModels 的新文件夹,并添加一个名为 MainPageViewModel.cs 的新文件。让我们开始使用 MVVMCross 实现我们的第一个视图模型:
namespace AudioPlayer.Portable.ViewModels
{
using MvvmCross.Core.ViewModels;
public class MainPageViewModel : MvxViewModel
{
public MainPageViewModel()
{
}
}
}
当我们构建我们的Xamarin.Forms视图模型时,我们创建了自己的基视图模型来处理属性更改;使用这个库,我们可以对基属性进行一些简化。MvxViewModel在处理属性更改方面有类似的实现;对于我们的MainPage,我们将开发与上一章相同的第一页,所以让我们从私有属性开始:
public class MainPageViewModel : MvxViewModel
{
#region Private Properties
private string _descriptionMessage = "Welcome to the Music Room";
private string _audioPlayerTitle = "Audio Player";
private string _exitTitle = "Exit";
private MvxCommand _audioPlayerCommand;
private MvxCommand _exitCommand;
#endregion
}
注意我们如何使用不同的Command类型,称为MvxCommand?它与Xamarin.Forms.Command非常相似。让我们添加公共属性并看看我们如何处理属性更改:
#region Public Properties
public string DescriptionMessage
{
get
{
return _descriptionMessage;
}
set
{
if (value.Equals(_descriptionMessage))
{
_descriptionMessage = value;
RaisePropertyChanged (() => DescriptionMessage);
}
}
}
public MvxCommand AudioPlayerCommand
{
get
{
return _audioPlayerCommand;
}
set
{
if (value.Equals(_audioPlayerCommand))
{
_audioPlayerCommand = value;
RaisePropertyChanged (() => AudioPlayerCommand);
}
}
}
#endregion
简单,对吧?
这与set函数完全相同。我们正在检查值是否已更改;如果已更改,则设置private属性并调用RaisePropertyChanged。唯一的区别是我们通过public属性将操作传递给函数。
现在我们可以开始构建MainPage的用户界面了。这次,我们将完全基于.cs文件来开发 iOS 界面。添加一个新的.cs文件,并将其命名为MainPage.cs:
[MvxViewFor(typeof(MainPageViewModel))]
public partial class MainPage : MvxViewController
{
public MainPage ()
{
}
}
创建绑定
我们的第一步是构建用户界面。我们将在视图控制器中添加两个UIButtons、一个UILabel和一个UIImageView:
public override void ViewDidLoad ()
{
base.ViewDidLoad ();
var mainView = new UIView ()
{
TranslatesAutoresizingMaskIntoConstraints = false,
BackgroundColor = UIColor.White
};
var imageView = new UIImageView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
ContentMode = UIViewContentMode.ScaleAspectFit,
Image = new UIImage("audio.png")
};
var descriptionLabel = new UILabel ()
{
TranslatesAutoresizingMaskIntoConstraints = false,
TextAlignment = UITextAlignment.Center
};
var audioPlayerButton = new UIButton (UIButtonType.RoundedRect)
{
TranslatesAutoresizingMaskIntoConstraints = false
};
var exitButton = new UIButton (UIButtonType.RoundedRect)
{
TranslatesAutoresizingMaskIntoConstraints = false
};
View.Add (mainView);
// add buttons to the main view
mainView.Add (imageView);
mainView.Add (descriptionLabel);
mainView.Add (audioPlayerButton);
mainView.Add (exitButton);
}
现在让我们为用户界面元素创建绑定。将以下内容添加到ViewDidLoad函数的底部:
var set = this.CreateBindingSet<MainPage, MainPageViewModel> ();
set.Bind(this).For("Title").To(vm => vm.Title);
set.Bind(descriptionLabel).To(vm => vm.DescriptionMessage);
set.Bind(audioPlayerButton).For("Title").To(vm => vm.AudioPlayerTitle);
set.Bind(audioPlayerButton).To(vm => vm.AudioPlayerCommand);
set.Bind(exitButton).For("Title").To(vm => vm.ExitTitle);
set.Bind(exitButton).To(vm => vm.ExitCommand);
set.Apply ();
当我们创建绑定上下文(BindingSet)时,我们将通过绑定集设置所有绑定。第一个绑定是与description标签的绑定。我们绑定的对象必须是一个字符串(DescriptionMessage是我们从视图模型中得到的字符串对象)。
进一步来说,我们可以使用For函数指定 UI 元素的特定属性,并在参数中指定属性的名称。在我们的例子中,我们指定了UIButton的Title属性,然后调用To函数来绑定指定的字符串对象。我们也将此操作应用于UIViewController。
最后,我们使用的最后一个绑定是我们视图模型中的MvxCommands。我们不需要指定属性名称;我们只需调用To函数并在视图模型中指定命令。
注意
在我们创建的UIImageView中,我们使用了一个名为audio.png的图片。你可以放入任何你喜欢的图片,只要图片的名称与在UIImage中加载的名称相匹配。本例中所有资源都可以通过 GitHub 链接找到:github.com/flusharcade/chapter4-audioplayer。
NSLayoutContraints
让我们更仔细地看看我们初始化 UI 元素的地方。TranslatesAutoresizingMaskIntoConstraints属性用于确定我们是否将使用NSLayoutConstraints来构建用户界面。当我们将其设置为false时,这意味着我们必须为该元素实现布局约束。
现在我们想使用布局约束来构建用户界面。在将元素添加到mainView之后,添加以下内容:
View.AddConstraints (NSLayoutConstraint.FromVisualFormat("V:|[mainView]|", NSLayoutFormatOptions.DirectionLeftToRight, null, new NSDictionary("mainView", mainView)));
View.AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|[mainView]|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("mainView", mainView)));
mainView.AddConstraints (NSLayoutConstraint.FromVisualFormat("V:|-80-[welcomeLabel]-[audioPlayerButton]-[exitButton]", NSLayoutFormatOptions.DirectionLeftToRight, null, new NSDictionary("welcomeLabel", welcomeLabel, "audioPlayerButton", audioPlayerButton, "exitButton", exitButton)));
mainView.AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|-5-[welcomeLabel]-5-|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("welcomeLabel", welcomeLabel)));
mainView.AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|-5-[audioPlayerButton]-5-|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("audioPlayerButton", audioPlayerButton)));
mainView.AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|-5-[exitButton]-5-|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("exitButton", exitButton)));
在前两行中,我们为 UIView 添加了约束。由于视图只包含一个 UIView,我们为 mainView 对象的垂直和水平属性创建了两个约束。vertical 属性设置为以下内容:
"V:|[mainView]|"
这意味着 mainView 将拉伸到包含视图的整个高度,对于 horizontal 属性也是如此:
"H:|[mainView]|"
mainView 对象的宽度将被拉伸到包含视图的整个宽度。这两行文本被称为 VisualFormat.NSLayoutContraints,它们使用文本输入作为视觉表示,描述了视图如何在父视图中呈现。
看看我们传递给 AddConstraints 函数的其他属性,我们传递了 NSLayoutFormatOption,用于视图遵守(即,左对齐/顶部对齐),然后是指标和 NSDictionary,它将包含涉及约束的 UI 元素。你会注意到一些其他的约束,例如这些:
"H:|-5-[audioPlayerButton]-5-|"
这些约束包括围绕 UI 元素的填充:
"H:|-[audioPlayerButton]-|"
我们甚至可以简单地围绕 UI 元素放置一个破折号字符,这将放置默认填充 8。
在 PCL 内部设置 MVVMCross
进一步深入到 MVVMCross 框架中,让我们首先构建 MvxApplication 类。
注意
这与 Xamarin.Forms 应用程序内的应用程序类不同。
public class App : MvxApplication
{
public override void Initialize()
{
CreatableTypes()
.EndingWith("Service")
.AsInterfaces()
.RegisterAsLazySingleton();
}
}
注意到正在调用的 CreatableTypes 函数;该函数使用反射来查找核心程序集中的所有 Creatable 类,这意味着它们有一个公共构造函数,并且它们不是抽象的。然后,在此函数之后,仅注册以 Service 结尾的类接口作为懒单例。
注意
懒单例确保如果一个类实现了 IOne 和 ITwo,则在解析 IOne 和 ITwo 时将返回相同的实例。
需要在 Application 类中添加另一个部分。我们必须注册启动点,因此请在 RegisterAsLazySingleton 函数下添加以下行:
RegisterAppStart<MainPageViewModel>();
在 iOS 中设置 MVVMCross
现在,我们转向 iOS 项目。对于每个平台,我们必须实现一个 Setup 类,该类将用于实例化 MvxApplication 类。添加一个名为 IosSetup 的新类,并实现以下内容:
public class IosSetup : MvxIosSetup
{
public IosSetup(MvxApplicationDelegate applicationDelegate, UIWindow window) : base(applicationDelegate, window)
{
}
protected override IMvxApplication CreateApp()
{
return new App();
}
protected override IMvxTrace CreateDebugTrace()
{
return new DebugTrace();
}
}
首先,我们必须包含一个接受 MvxApplicationDelegate 和 UIWindow 的构造函数;这些将在实例化时传递给基类。我们还有两个作为 MvxIosSetup 对象一部分被重写的函数。
从 CreateApp 函数开始。我们在这里所做的只是实例化我们之前实现的 MvxApplication 类。当我们实现 AppDelegate 类时,我们将对此进行更详细的分解。
我们还必须重写 CreateDebugTrace 函数,这将实例化一个新的 DebugTrace 对象。首先,让我们在我们的 PCL 项目中创建一个名为 Logging 的新文件夹,添加一个名为 DebugTrace.cs 的新文件,并实现以下内容:
public class DebugTrace : IMvxTrace
{
public void Trace(MvxTraceLevel level, string tag, Func<string> message)
{
Debug.WriteLine(tag + ":" + level + ":" + message());
}
public void Trace(MvxTraceLevel level, string tag, string message)
{
Debug.WriteLine(tag + ":" + level + ":" + message);
}
public void Trace(MvxTraceLevel level, string tag, string message, params object[] args)
{
try
{
Debug.WriteLine(string.Format(tag + ":" + level + ":" + message, args));
}
catch (FormatException)
{
Trace(MvxTraceLevel.Error, tag, "Exception during trace of {0} {1}", level, message);
}
}
}
作为 IMvxTrace 接口的一部分,我们必须实现所有这些功能。这些功能并不复杂;我们只是在调用这些功能时捕获错误并将文本输出到控制台。所有通过 DebugTrace 对象调用的功能都通过一个单例对象路由。我们将在两个平台项目中共享这个对象。
太好了!现在我们已经完成了所有 iOS 的 MVVMCross 要求,让我们通过 AppDelegate 类将这些内容组合起来:
public override bool FinishedLaunching (UIApplication application, NSDictionary launchOptions)
{
_window = new UIWindow (UIScreen.MainScreen.Bounds);
var setup = new IosSetup(this, window);
setup.Initialize();
var startup = Mvx.Resolve<IMvxAppStart>();
startup.Start();
_window.MakeKeyAndVisible ();
return true;
}
我们在 FinishedLaunching 函数中到底做了什么?
首先,我们将 UIWindow 实例化为主屏幕边界的大小。然后,我们通过传递新的 UIWindow 对象实例化 IosSetup 类,并调用我们在 PCL 中的 MvxApplication 实现的 Initialize 函数。然后,我们使用 Mvx IoC 容器解析 IMvxAppStart 接口,并调用 Start 以在 MainPageViewModel 上开始应用程序。
太棒了!我们现在已经设置了 MVVMCross 与我们的 iOS 项目;接下来,让我们为 Android 项目做同样的事情。
设置 MVVMCross 与 Android
由于我们已经完成了 MVVMCross 的 PCL 设置,我们只需要创建一个设置对象,该对象将继承 MvxAndroidSetup 类。
创建一个名为 AndroidSetup.cs 的新文件,并实现以下内容:
public class AndroidSetup : MvxAndroidSetup
{
public AndroidSetup(Context context) :base(context)
{
}
protected override IMvxApplication CreateApp()
{
return new App();
}
protected override IMvxTrace CreateDebugTrace()
{
return new DebugTrace();
}
}
这与 iOS 设置非常相似,但在构造函数中我们必须传递 Android 上下文。
现在是 Android 的最终设置。我们通常不需要重写应用程序。相反,MVVMCross 默认提供了一个启动画面。删除自动创建的 MainActivity 类,并用一个新的活动 SplashScreenActivity 替换它:
[Activity(Label = "AudioPlayer.Droid"
, MainLauncher = true
, Icon = "@drawable/icon"
, Theme = "@style/Theme.Splash"
, NoHistory = true
, ScreenOrientation = ScreenOrientation.Portrait)]
public class SplashScreenActivity : MvxSplashScreenActivity
{
public SplashScreenActivity(): base(Resource.Layout.SplashScreen)
{
}
}
我们不需要在我们的构造函数中添加任何内容,但我们必须将 MainLauncher = true 标志添加到属性中,以确保这是平台启动时创建的第一件事。我们还必须创建启动画面活动的新的 XML 视图。在这个例子中,我们将创建一个简单的屏幕,包含一个 TextView:
小贴士
尝试创建一个启动画面,显示一个图像以提供应用程序的品牌。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<TextView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="Loading...."/>
</LinearLayout>
那就是全部了;让我们测试运行这两个平台,我们应该现在看到以下屏幕:

SoundHandler 接口
在多个平台上播放音频的问题是我们处理音频时不能共享太多代码。我们必须创建一个接口,并通过 IoC 容器注册实现。
我们的下一步是创建 ISoundHandler 接口。在 AudioPlayer.Portable 项目中,添加一个名为 Sound 的新文件夹。在这个文件夹中,添加一个名为 ISoundHandler.cs 的新文件,并实现以下内容:
public interface ISoundHandler
{
bool IsPlaying { get; set; }
void Load();
void PlayPause();
void Stop();
double Duration();
void SetPosition(double value);
double CurrentPosition();
void Forward();
void Rewind();
}
我们的接口将描述我们将通过 AudioPlayerPage 接口使用到的所有功能。
现在让我们开始 iOS 的实现。
使用 AVAudioPlayer 框架实现 iOS SoundHandler
AVAudioPlayer类是我们将在 iOS 中用于播放和控制音频流的框架,所以让我们首先在我们的 iOS 项目中添加一个名为Sound的新文件夹。然后我们想要创建一个名为SoundHandler.cs的新文件,该文件将继承ISoundHandler接口:
public class SoundHandler : ISoundHandler
{
}
现在让我们创建一个私有的AVAudioPlayer对象,并添加我们的公共IsPlaying,它将保存音频播放器的播放状态:
private AVAudioPlayer _audioPlayer;
public bool IsPlaying { get; set; }
然后我们添加接口的函数。在每个函数中,我们将使用音频播放器对象来完成所有的音频处理:
public void Load()
{
_audioPlayer = AVAudioPlayer.FromUrl(NSUrl.FromFilename("Moby - The Only Thing.mp3"));
}
public void PlayPause()
{
if (_audioPlayer != null)
{
if (IsPlaying)
{
_audioPlayer.Stop();
}
else
{
_audioPlayer.Play();
}
IsPlaying = !IsPlaying;
}
}
第一个函数将从Resources文件夹中加载文件。在这个例子中,我们将加载一首 Moby 的歌曲(我个人非常喜欢的一首)。
注意
你可以添加任何音频文件,只要文件名与通过NSURL对象加载的文件名匹配。如果你想使用与这个相同的文件,请访问之前提到的 GitHub 链接。
第二个函数将控制音频的播放和停止。如果我们首先点击播放按钮,它将播放并将IsPlaying的状态设置为true。然后如果我们再次点击播放按钮,它将停止音频并将IsPlaying设置为false。
现在让我们继续实现其余部分:
public void Stop()
{
if (_audioPlayer != null)
{
_audioPlayer.Stop();
}
}
public double Duration()
{
if (_audioPlayer != null)
{
return _audioPlayer.Duration;
}
return 0;
}
public void SetPosition(double value)
{
if (_audioPlayer != null)
{
_audioPlayer.CurrentTime = value;
}
}
public double CurrentPosition()
{
if (_audioPlayer != null)
{
return _audioPlayer.CurrentTime;
}
return 0;
}
public void Forward()
{
if (_audioPlayer != null)
{
IsPlaying = false;
_audioPlayer.Stop();
_audioPlayer.CurrentTime = audioPlayer.Duration;
}
}
public void Rewind()
{
if (_audioPlayer != null)
{
IsPlaying = false;
_audioPlayer.Stop();
_audioPlayer.CurrentTime = 0;
}
}
所有这些都是直截了当的:我们的Stop函数将停止音频。我们的Rewind函数将停止音频并将当前时间设置为 0(意味着音频流的开始)。我们的Forward函数将停止音频并将当前时间移动到流的末尾。最后两个函数将设置音频流的当前位置为传入的双值。这将与我们的进度滑块一起使用;当滑块位置改变时,值将传递到这个函数以更新音频流的当前位置。最后,最后一个函数将检索当前时间值,以便我们可以用这个细节更新我们的用户界面。
太好了!现在我们已经为 iOS 实现了声音处理,我们想要通过 IoC 容器注册它。
Mvx IoC 容器
MVVMCross 自带其自己的 IoC 容器。它的工作方式与我们的上一个例子中的 Autofac 完全一样,但我们不会使用模块。让我们首先注册我们的声音处理实现;打开我们的AppDelegate.cs文件,创建一个名为setupIoC的新私有函数:
private void SetupIoC()
{
Mvx.RegisterType<ISoundHandler, SoundHandler>();
}
我们还必须注册我们的视图模型,这样我们才能在视图模型的构造函数中检索已注册的接口。让我们在我们的AudioPlayer.Portable项目中添加一个名为IoC的新文件夹。添加一个名为PortableMvxIoCRegistrations.cs的新文件,并实现以下内容:
public static class PortableMvxIoCRegistrations
{
public static void InitIoC()
{
Mvx.IocConstruct<MainPageViewModel>();
Mvx.IocConstruct<AudioPlayerPageViewModel>();
}
}
现在我们必须从AppDelegate函数SetupIoC中调用静态函数InitIoC:
private void SetupIoC()
{
Mvx.RegisterType<ISoundHandler, SoundHandler>();
PortableMvxIoCRegistrations.InitIoC();
}
现在我们已经将所有需要注册的内容都注册到 IoC 容器中,让我们开始构建AudioPlayerPage。
音频播放器
在这个项目中,我们的下一步是构建控制音频的用户界面。在Views文件夹内添加一个名为AudioPlayerPage.cs的新文件;别忘了在类声明上方添加属性以注册 MVVMCross 框架的视图模型:
[MvxViewFor(typeof(AudioPlayerPageViewModel))]
public class AudioPlayerPage : MvxViewController
{
private UIButton playButton;
private UISlider _progressSlider;
private bool _playing;
private AudioPlayerPageViewModel _model;
}
注意
我们声明了一些需要在多个函数中使用的局部作用域变量;你将看到这些是如何被使用的。
现在让我们通过ViewDidLoad函数创建 UI 元素:
public override void ViewDidLoad()
{
base.ViewDidLoad();
var mainView = new UIView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
BackgroundColor = UIColor.White
};
var buttonView = new UIView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
BackgroundColor = UIColor.Clear
};
var imageView = new UIImageView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
ContentMode = UIViewContentMode.ScaleAspectFit,
Image = new UIImage("moby.png")
};
var descriptionLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
TextAlignment = UITextAlignment.Center
};
var startLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
TextAlignment = UITextAlignment.Left,
};
var endLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
TextAlignment = UITextAlignment.Right,
};
_progressSlider = new UISlider()
{
TranslatesAutoresizingMaskIntoConstraints = false,
MinValue = 0
};
_playButton = new UIButton(UIButtonType.Custom)
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
var rewindButton = new UIButton(UIButtonType.Custom)
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
var fastForwardButton = new UIButton(UIButtonType.Custom)
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
}
我们有标签用于显示当前曲目名称、开始时间和结束时间。我们还有控制音频流的按钮(播放、暂停、快退和快进)。最后,我们有进度滑块用于动画当前音频时间;我们还将使用它来改变音频的位置。
我们现在想要为按钮添加控制按钮图像上一些 UI 变化的按钮事件;在播放按钮声明下添加事件处理程序分配:
_playButton.TouchUpInside += HandlePlayButton;
_playButton.SetImage(UIImage.FromFile("play.png"), UIControlState.Normal);
注意
TouchUpInside事件会在我们点击按钮时触发。
然后创建事件处理程序函数:
private void HandlePlayButton(object sender, EventArgs e)
{
_playing = !_playing;
_playButton.SetImage(UIImage.FromFile(playing ? "pause.png" : "play.png"), UIControlState.Normal);
}
每次我们点击播放按钮时,它都会在播放和暂停图标之间移动图像。现在让我们添加快退和快进按钮处理程序;在每个 UI 元素声明下添加以下行:
rewindButton.TouchUpInside += HandleRewindForwardButton;
rewindButton.SetImage(UIImage.FromFile("rewind.png"), UIControlState.Normal);
fastForwardButton.TouchUpInside += HandleRewindForwardButton;
fastForwardButton.SetImage(UIImage.FromFile("fast_forward.png"), UIControlState.Normal);
现在我们添加事件处理程序函数:
private void HandleRewindForwardButton(object sender, EventArgs e)
{
_playing = false;
_playButton.SetImage(UIImage.FromFile("play.png"), UIControlState.Normal);
}
这与播放按钮处理程序类似,但这次我们总是将播放状态设置为 false,并将播放按钮图像设置为播放图标。
注意
对于所有音频图像,请访问之前给出的 GitHub 链接。
NSLayout 的更简洁的代码方法
在我们之前的屏幕上,我们使用NSLayoutContraints构建了一个非常简单的用户界面。
你会同意代码看起来相当笨拙吗?
使用我们的AudioPlayerPage,我们将采用更简洁的方法来编写NSLayoutConstraints。首先,创建一个名为Extras的新文件夹,并添加一个名为DictionaryViews.cs的新文件:

这个类将继承IEnumerable接口以创建一个NSDictionary;该接口的一部分是我们必须指定GetEnumerator函数。它将从NSDictionary中获取这个函数;我们还有一个Add函数,它简单地将一个新的UIView添加到字典中。然后我们有静态隐式转换操作符,它将返回一个NSDictionary对象(这是为了我们可以直接将对象作为NSDictionary传递给FromVisualLayout函数):
public class DictionaryViews : IEnumerable
{
private readonly NSMutableDictionary _nsDictionary;
public DictionaryViews()
{
_nsDictionary = new NSMutableDictionary();
}
public void Add(string name, UIView view)
{
_nsDictionary.Add(new NSString(name), view);
}
public static implicit operator NSDictionary(DictionaryViews us)
{
return us.ToNSDictionary();
}
public NSDictionary ToNSDictionary()
{
return _nsDictionary;
}
public IEnumerator GetEnumerator()
{
return ((IEnumerable)_nsDictionary).GetEnumerator();
}
}
现在我们继续在AudioPlayerPage内部创建这些之一;在快进按钮声明下粘贴以下内容:
var views = new DictionaryViews()
{
{"mainView", mainView},
{"buttonView", buttonView},
{"imageView", imageView},
{"descriptionLabel", descriptionLabel},
{"startLabel", startLabel},
{"endLabel", endLabel},
{"progressSlider", progressSlider},
{"playButton", playButton},
{"rewindButton", rewindButton},
{"fastForwardButton", fastForwardButton}
};
太好了!我们现在有一个新的IEnumerable/NSDictionary,其中包含整个界面中需要使用的所有必需视图。我们可以直接将此对象传递给NSLayoutConstraint函数FromVisualFormat,这样我们就不需要在创建每个NSLayoutContraint时重复声明新的字典。现在将所有 UI 元素添加到正确的父视图中:
View.Add(mainView);
mainView.Add(imageView);
mainView.Add(descriptionLabel);
mainView.Add(buttonView);
mainView.Add(startLabel);
mainView.Add(endLabel);
mainView.Add(progressSlider);
buttonView.Add(playButton);
buttonView.Add(rewindButton);
buttonView.Add(fastForwardButton);
然后让我们构建所有的NSLayoutConstraints;我们的第一个是UIViewController'sUIView:
View.AddConstraints(
NSLayoutConstraint.FromVisualFormat("V:|[mainView]|", NSLayoutFormatOptions.DirectionLeftToRight, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("H:|[mainView]|", NSLayoutFormatOptions.AlignAllTop, null, views))
.ToArray());
我们使用System.Linq函数Concat来组合所有必需的NSLayoutContraints,用于视图。我们只需要调用一次AddConstraints函数,并传递一个包含该父视图所需所有约束的数组。
让我们添加对mainView和buttonView的约束:
mainView.AddConstraints(
NSLayoutConstraint.FromVisualFormat("V:|-100-[imageView(200)]-[descriptionLabel(30)]-[buttonView(50)]-[startLabel(30)]-[progressSlider]", NSLayoutFormatOptions.DirectionLeftToRight, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("V:|-100-[imageView(200)]-[descriptionLabel(30)]-[buttonView(50)]-[endLabel(30)]-[progressSlider]", NSLayoutFormatOptions.DirectionLeftToRight, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-20-[progressSlider]-20-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-25-[startLabel(70)]", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:[endLabel(70)]-25-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-5-[descriptionLabel]-5-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-5-[imageView]-5-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(new[] { NSLayoutConstraint.Create(buttonView, NSLayoutAttribute.CenterX, NSLayoutRelation.Equal, mainView, NSLayoutAttribute.CenterX, 1, 0) })
.ToArray());
buttonView.AddConstraints(
NSLayoutConstraint.FromVisualFormat("V:|-5-[rewindButton]-5-|", NSLayoutFormatOptions.AlignAllTop, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("V:|-5-[playButton]-5-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("V:|-5-[fastForwardButton]-5-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-20-[rewindButton]-[playButton(100)]-[fastForwardButton]-20-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.ToArray());
这完全相同的方法,但看起来更美观,并且减少了调用AddConstraints的次数。视图只需要添加所有约束一次,并布局一次元素,因此效率更高。
构建用户界面的最后一步是设置 MVVMCross 绑定;我们使用与MainPage相同的方法。让我们在AudioPlayerPage和AudioPlayerPageViewModel之间创建一个新的绑定集:
var set = CreateBindingSet<AudioPlayerPage, AudioPlayerPageViewModel>();
set.Apply();
在我们开始创建绑定之前,让我们首先为AudioPlayer.Portable项目构建AudioPlayerPageViewModel。
创建 AudioPlayerPageViewModel
我们的AudioPlayerPageViewModel必须包含我们的ISoundHandler接口。我们将从这个视图模型控制音频,因此我们的按钮可以启动声音处理程序上所需的事件。让我们首先在ViewModels文件夹中创建一个名为AudioPlayerPageViewModel.cs的新文件,并从实现私有属性开始:
public class AudioPlayerPageViewModel : MvxViewModel
{
#region Private Properties
private readonly ISoundHandler _soundHandler;
private string _title = "Audio Player";
private string _descriptionMessage = "Moby - The Only Thing";
private MvxCommand _playPauseCommand;
private MvxCommand _forwardCommand;
private MvxCommand _rewindCommand;
private float _audioPosition;
private double _currentTime;
private double _endTime;
private bool _updating;
#endregion
然后我们必须添加public属性。
注意
我们只展示两个public属性作为示例,因为代码是重复的。
public MvxCommand PlayPauseCommand
{
get
{
return _playPauseCommand;
}
set
{
if (!value.Equals(_playPauseCommand))
{
_playPauseCommand = value;
RaisePropertyChanged (() => PlayPauseCommand);
}
}
}
public MvxCommand RewindCommand
{
get
{
return _rewindCommand;
}
set
{
if (!value.Equals(_rewindCommand))
{
_rewindCommand = value;
RaisePropertyChanged(() => RewindCommand);
}
}
}
我们还需要添加这两个公共变量,它们将接受CurrentTime和EndTime的双精度值,并从TimeSpan值创建一个格式化的字符串。
注意我们也在双 setter 中的字符串上调用RaisePropertyChanged吗?每次我们获取一个新的当前时间值时,格式化的字符串也需要更新:
public string CurrentTimeStr
{
get
{
return TimeSpan.FromSeconds(CurrentTime).ToString("mm\\:ss");
}
}
public double CurrentTime
{
get
{
return _currentTime;
}
set
{
if (!value.Equals(_currentTime))
{
_currentTime = value;
RaisePropertyChanged(() => CurrentTime);
// everytime we change the current time, the time span values must also update
RaisePropertyChanged(() => CurrentTimeStr);
}
}
}
public string EndTimeStr
{
get
{
return TimeSpan.FromSeconds(EndTime).ToString("mm\\:ss");
}
}
public double EndTime
{
get
{
return _endTime;
}
set
{
if (!value.Equals(_endTime))
{
_endTime = value;
RaisePropertyChanged(() => EndTime);
RaisePropertyChanged(() => EndTimeStr);
}
}
}
现在是我们的构造函数:
#region Constructors
public AudioPlayerPageViewModel (ISoundHandler soundHandler)
{
_soundHandler = soundHandler;
// load sound file
_soundHandler.Load();
EndTime = _soundHandler.Duration();
}
#endregion
在这里,我们从 IoC 容器中提取了ISoundHandler实现,因为我们将在 IoC 容器中注册这个视图模型。
我们的下一步是向视图模型添加两个新函数,Load和Dispose。这两个函数将在AudioPlayerPage显示和消失时被调用,并且当音频流开始和停止时也会被使用。
让我们先添加Load函数:
public void Load()
{
// make sure we only start the loop once
if (!_updating)
{
_updating = true;
// we are going to post a regular update to the UI with the current time
var context = SynchronizationContext.Current;
Task.Run(async () =>
{
while (_updating)
{
await Task.Delay(1000);
context.Post(unused =>
{
var current = _soundHandler.CurrentPosition(); ;
if (current > 0)
{
CurrentTime = current;
}
}, null);
}
});
}
}
当页面显示和音频流开始时,将调用Load函数。该函数使用Task框架在后台运行一个重复循环,因此每秒我们将从ISoundHandler接口检索音频流的当前时间。我们将更新传播到AudioPlayerPage接口上的当前时间标签。
注意我们是如何使用SynchronisationContext.Current变量的?
这用于线程目的,所以我们确保我们在主 UI 线程上设置我们的CurrentTime变量。由于这个循环是在一个单独的线程上运行的,如果我们在这个变量上进行了更改,它将破坏应用程序,因为你在尝试在主 UI 线程之外进行 UI 更改。
对于Dispose函数;这个函数将在AudioPlayerPage消失和音频流停止时被调用(当音频流没有播放时,我们不需要更新 UI)。这确保了当页面不可见时停止后台循环:
public void Dispose()
{
_updating = false;
_soundHandler.Stop();
}
私有变量_updating用于控制后台循环是否正在运行的状态,因此我们确保在任何时候只有一个后台循环正在运行。
现在让我们开始音频命令:
_playPauseCommand = new MvxCommand(() =>
{
// start/stop UI updates if the audio is not playing
if (soundHandler.IsPlaying)
{
Dispose();
}
else
{
Load();
}
_soundHandler.PlayPause();
});
_rewindCommand = new MvxCommand(() =>
{
// set current time to the beginning
CurrentTime = 0;
_soundHandler.Rewind();
Dispose();
});
_forwardCommand = new MvxCommand(() =>
{
// set current time to the end
CurrentTime = _soundHandler.Duration();
_soundHandler.Forward();
Dispose();
});
更仔细地查看这些命令,使用PlayPauseCommand,我们将根据音频流的播放状态调用Load或Dispose,并且它还会在ISoundHandler接口上调用PlayPause,这控制着音频流。rewindCommand属性将当前时间设置为 0,将音频流的当前时间设置为 0,并停止后台循环。forwardCommand属性将当前时间设置为音频流的结束持续时间(它将从ISoundHandler接口检索),将音频流的当前时间设置为结束持续时间,并停止后台循环。
最后,我们必须创建一个public函数来设置音频流的当前时间。这个函数将在进度条值每次变化时被我们的进度条使用,这个函数将被调用:
public void UpdateAudioPosition(double value)
{
_soundHandler.SetPosition(value);
}
现在回到AudioPlayerPage并添加最终的功能。
由于我们在绑定到视图的视图模型之前声明了一个局部变量,我们希望将其从UIView的数据上下文中提取出来:
_model = (AudioPlayerPageViewModel)DataContext;
我们的局部变量绑定了一个视图模型。我们需要从我们的视图中调用视图模型的一些公共方法。我们必须在进度条声明的下面添加对进度条ValueChanged事件的event handler。添加以下内容:
progressSlider.ValueChanged += ProgressSliderValueChanged;
然后创建事件处理函数:
private void ProgressSliderValueChanged(object sender, EventArgs e)
{
_model.UpdateAudioPosition(_progressSlider.Value);
}
当页面出现时,添加对Load函数的调用:
public override void ViewDidAppear(bool animated)
{
_model.Load();
base.ViewDidAppear(animated);
}
重写ViewDidDisappear以调用Dispose函数:
public override void ViewDidDisappear(bool animated)
{
_model.Dispose();
base.ViewDidDisappear(animated);
}
在绑定集中创建以下绑定:
set.Bind(this).For("Title").To(vm => vm.Title);
set.Bind(descriptionLabel).To(vm => vm.DescriptionMessage);
set.Bind(currentLabel).To(vm => vm.CurrentTime);
set.Bind(endLabel).To(vm => vm.EndTime);
set.Bind(progressSlider).For(v => v.Value).To(vm => vm.CurrentTime);
set.Bind(progressSlider).For(v => v.MaxValue).To(vm => vm.EndTime);
set.Bind(playButton).To(vm => vm.PlayPauseCommand);
set.Bind(rewindButton).To(vm => vm.RewindCommand);
set.Bind(fastForwardButton).To(vm => vm.ForwardCommand);
我们将标签绑定到描述,这些是硬编码的。这就是为什么我们必须在主 UI 线程上更改CurrentTime变量,因为它会影响currentLabel上显示的内容。我们还在音频按钮上绑定了MvxCommand。最后,我们在进度条的Value属性上绑定了绑定,以匹配CurrentTime变量,并将MaxValue与音频流的结束时间相匹配,以便与音频流的播放百分比相匹配。
太棒了!尝试运行应用程序,并尝试播放/暂停和进度滑块功能。
让我们继续构建 Android 版本的等效功能。
使用 MediaPlayer 框架实现 Android SoundHandler
要在 Android 中实现声音处理接口的相同功能,我们将使用MediaPlayer框架。
让我们在 Android 项目中创建一个新的文件夹,命名为Sound,并创建一个名为SoundHandler.cs的新文件:
public class SoundHandler : ISoundHandler
{
private MediaPlayer _mediaPlayer;
public bool IsPlaying { get; set; }
}
与 iOS 版本相同,让我们添加Load和PlayPause函数:
public void Load()
{
try
{
_mediaPlayer = new MediaPlayer();
_mediaPlayer.SetAudioStreamType(Stream.Music);
AssetFileDescriptor descriptor = Android.App.Application.Context.Assets.OpenFd("Moby - The Only Thing.mp3");
_mediaPlayer.SetDataSource(descriptor.FileDescriptor, descriptor.StartOffset, descriptor.Length);
_mediaPlayer.Prepare();
_mediaPlayer.SetVolume(1f, 1f);
}
catch (Exception e)
{
Debug.WriteLine(e);
}
}
public void PlayPause()
{
if (_mediaPlayer != null)
{
if (IsPlaying)
{
_mediaPlayer.Pause();
}
else
{
_mediaPlayer.Start();
}
IsPlaying = !IsPlaying;
}
}
在Load函数中,我们有一些异常处理,以防文件由于任何原因无法加载;这将阻止我们的应用程序崩溃。当你将.mp3放入 Android 项目时,它必须放在Assets文件夹中,并确保文件的构建操作设置为AndroidAsset:

在我们的load函数中,初始化MediaPlayer对象之后,我们将流类型设置为Stream.Music,然后使用AssetFileDescriptor检索.mp3文件。然后,我们将MediaPlayer的源设置为来自AssetFileDescriptor的.mp3文件。接下来,我们调用Prepare并设置音量为最大(1.0f)。
我们的PlayPause函数非常简单;我们只需检查音频是否正在播放,以确定是暂停还是开始音频流。
现在是其他函数:
public void Stop()
{
if (_mediaPlayer != null)
{
_mediaPlayer.Stop();
_mediaPlayer.Reset();
}
}
public double Duration()
{
if (_mediaPlayer != null)
{
return _mediaPlayer.Duration / 1000;
}
return 0;
}
public void SetPosition(double value)
{
if (_mediaPlayer != null)
{
_mediaPlayer.SeekTo((int)value * 1000);
}
}
public double CurrentPosition()
{
if (_mediaPlayer != null)
{
return _mediaPlayer.CurrentPosition / 1000;
}
return 0;
}
public void Forward()
{
if (_mediaPlayer != null)
{
IsPlaying = false;
_mediaPlayer.Pause();
_mediaPlayer.SeekTo(_mediaPlayer.Duration);
}
}
public void Rewind()
{
if (_mediaPlayer != null)
{
IsPlaying = false;
_mediaPlayer.Pause();
_mediaPlayer.SeekTo(0);
}
}
Stop函数需要在调用Stop之后在MediaPlayer上调用Reset函数。Duration和CurrentPosition函数需要将值除以 1,000,因为MediaPlayer的值是以毫秒为单位的。当我们调用MediaPlayer上的SeekTo时也是如此;因为我们传递的是以秒为单位的值,所以它必须乘以 1,000 以得到以毫秒为单位的答案。然后是Rewind和Forward函数;我们必须首先Pause音频流,然后调用SeekTo方法来设置流位置。
太棒了!我们现在有了ISoundHandler接口的 Android 实现,所以让我们开始构建 Android 用户界面。
XML 和 Mvx 绑定
我们的 Android 用户界面将从 MainPage 开始,因此我们需要添加一个名为 MainPage.xml 的新文件,以及一个名为 MainPage.cs 的新 MvxActivity。首先,添加一个名为 Views 的新文件夹;这是我们将会存储我们的 MvxActivities 的地方。让我们将一个名为 MainPage.cs 的新文件添加到 Views 文件夹中,并在 资源 | 布局 文件夹中创建一个名为 Main.xml 的新文件。我们的 Main.xml 将以 LinearLayout 开始,并包含四个元素:ImageView、TextView 和两个 Buttons:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:gravity="center">
<ImageView
android:id="@+id/AudioImage"
android:layout_width="200dp"
android:layout_height="200dp"
android:src="img/audio" />
<TextView
android:id="@+id/DescriptionText"
android:textSize="32sp"
android:layout_marginBottom="5dp"
android:layout_marginTop="5dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
local:MvxBind="Text DescriptionMessage" />
<Button
android:id="@+id/AudioPlayerButton"
android:layout_width="200dp"
android:layout_height="wrap_content"
local:MvxBind="Text AudioPlayerTitle; Click AudioPlayerCommand" />
<Button
android:id="@+id/ExitButton"
android:layout_width="200dp"
android:layout_height="wrap_content"
local:MvxBind="Text ExitTitle; Click ExitCommand" />
</LinearLayout>
让我们更仔细地看看 Buttons 和 TextView 上的 local:Mvxbind 属性。这是我们将会设置到视图模型的绑定。我们还需要添加以下这一行:
这看起来熟悉吗?
它与我们的 Xamarin.Forms 中的 XAML 表格相同;我们必须导入此命名空间,以便我们可以在我们的 UI 元素上使用绑定属性。
注意
在尝试构建项目之前,别忘了将所有图像复制到 drawable 文件夹中。
MvxActivities
MvxActivities 是从常规 Android Activity 扩展而来的对象;应用程序知道我们正在使用 MVVMCross 绑定系统。
让我们实现 MainPageMvxActivity:
[Activity(Label = "Audio Player")]
public class MainPage : MvxActivity
{
protected override void OnCreate(Bundle bundle)
{
base.OnCreate(bundle);
SetupIoC();
SetContentView(Resource.Layout.MainPage);
}
private void SetupIoC()
{
Mvx.RegisterType<ISoundHandler, SoundHandler>();
PortableMvxIoCRegistrations.InitIoC();
}
}
当此活动创建时,我们需要在 IoC 容器中设置我们的 IoC 注册。然后我们只需将内容视图设置为之前创建的 XML 表格。让我们测试一下 Android 应用程序并点击运行;你现在应该有一个像这样的 MainPage 屏幕:

现在我们继续到有趣的部分:让我们为 AudioPlayerPage 添加一个新的 .xml 和 MvxActivity。在我们开始实现此页面的用户界面之前,我们需要创建一个自定义的 SeekBar,因为我们想要为 "UP" 动作事件注册一个新的类型的事件。创建一个名为 Controls 的新文件夹,并添加一个名为 CustomSeekBar.cs 的新文件,然后实现以下内容:
public class CustomSeekBar : SeekBar
{
public event EventHandler ValueChanged;
protected CustomSeekBar(IntPtr javaReference, JniHandleOwnership transfer)
: base(javaReference, transfer)
{
}
public CustomSeekBar(Context context)
: base(context)
{
}
public CustomSeekBar(Context context, IAttributeSet attrs)
: base(context, attrs)
{
}
public CustomSeekBar(Context context, IAttributeSet attrs, int defStyle)
: base(context, attrs, defStyle)
{
}
public override bool OnTouchEvent(MotionEvent evt)
{
if (!Enabled)
return false;
switch (evt.Action)
{
// only fire value change events when the touch is released
case MotionEventActions.Up:
{
if (ValueChanged != null)
{
ValueChanged(this, EventArgs.Empty);
}
}
break;
}
// we also want to fire all base motion events
base.OnTouchEvent(evt);
return true;
}
}
我们需要这样做自定义事件,因为我们正在将音频流的进度绑定到 SeekBar。由于我们想要控制音频位置,我们需要确保只有在我们完成移动 SeekBar 时才触发此事件。
为什么我们不能直接使用 ProgressChanged 事件,这不是同一件事吗?
如果我们将视图模型函数 UpdateAudioPosition 注册到 ProgressChanged 事件,每当后台循环更新当前时间属性时,SeekBar 将调用此事件并尝试每秒更新一次 SeekBar 的音频位置。
现在让我们为 AudioPlayerPage 构建 XML:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:gravity="center">
<ImageView
android:id="@+id/AudioImage"
android:layout_marginTop="20dp"
android:layout_marginBottom="80dp"
android:layout_width="200dp"
android:layout_height="200dp"
android:src="img/moby" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<ImageButton
android:id="@+id/RewindButton"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="img/rewind"
local:MvxBind="Click RewindCommand" />
<ImageButton
android:id="@+id/PlayButton"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="img/play"
local:MvxBind="Click PlayPauseCommand" />
<ImageButton
android:id="@+id/ForwardButton"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="img/fast_forward"
local:MvxBind="Click ForwardCommand" />
</LinearLayout>
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<TextView
android:id="@+id/CurrentTimeText"
android:textSize="32sp"
android:layout_marginBottom="5dp"
android:layout_marginTop="5dp"
android:layout_marginLeft="20dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left"
local:MvxBind="Text CurrentTimeStr" />
<TextView
android:id="@+id/EndTimeText"
android:textSize="32sp"
android:layout_marginBottom="5dp"
android:layout_marginTop="5dp"
android:layout_marginRight="20dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="right"
local:MvxBind="Text EndTimeStr" />
</LinearLayout>
<AudioPlayer.Droid.Controls.CustomSeekBar
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:id="@+id/seekBar"
local:MvxBind="Progress CurrentTime; Max EndTime" />
</LinearLayout>
它是一个相当大的 .xml 表格。从顶部开始,我们有 LinearLayout,其中包含在最顶部的 ImageView,我们将在这里显示专辑封面。然后我们有两个 LinearLayouts,它们包含水平方向的三个 ImageButtons 和 TextViews。这些是堆叠在一起的。
最后,我们在TextView项的底部添加了我们自定义的SeekBar。你会注意到在TextView项上使用了layout_weight属性,所以它们都有相同的宽度。然后我们使用gravity将每个标签浮动到SeekBar的两侧。
太棒了!现在让我们将AudioPlayerPage的MvxActivity添加到Views文件夹中,并实现以下内容:
[Activity(NoHistory = true)]
public class AudioPlayerPage : MvxActivity
{
private bool _playing;
private ImageButton _playButton;
private CustomSeekBar _seekBar;
private AudioPlayerPageViewModel _model;
protected override void OnCreate(Bundle bundle)
{
base.OnCreate(bundle);
SetContentView(Resource.Layout.AudioPlayerPage);
_seekBar = FindViewById<CustomSeekBar>(Resource.Id.seekBar);
_seekBar.ValueChanged += handleValueChanged;
_playButton = FindViewById<ImageButton>(Resource.Id.PlayButton);
_playButton.SetColorFilter(Color.White);
_playButton.Click += handlePlayClick;
var rewindButton = FindViewById<ImageButton>(Resource.Id.RewindButton);
rewindButton.SetColorFilter(Color.White);
rewindButton.Click += handleRewindForwardClick;
var forwardButton = FindViewById<ImageButton>(Resource.Id.ForwardButton);
forwardButton.SetColorFilter(Color.White);
forwardButton.Click += handleRewindForwardClick;
_model = (AudioPlayerPageViewModel)ViewModel;
}
}
它看起来与 iOS 页面非常相似。我们为每个音频按钮分配相同类型的事件。现在添加事件函数:
private void HandleValueChanged(object sender, System.EventArgs e)
{
_model.UpdateAudioPosition(_seekBar.Progress);
}
private void HandlePlayClick(object sender, System.EventArgs e)
{
_playing = !_playing;
_playButton.SetImageResource(playing ? Resource.Drawable.pause : Resource.Drawable.play);
}
private void handleRewindForwardClick(object sender, System.EventArgs e)
{
_playing = false;
_playButton.SetImageResource(Resource.Drawable.play);
}
protected override void OnDestroy()
{
_model.Dispose();
base.OnDestroy();
}
你会注意到这个Activity上的NoHistory标志被设置为 true,所以每次我们加载Activity时,它都会加载一个新的Activity,而不会加载任何之前创建的AudioPlayerPage。我们还重写了OnDestroy函数,以便它在我们的视图模型上调用Dispose方法。
下面这行 iOS 代码有等价物:
_model = (AudioPlayerPageViewModel)DataContext;
在安卓中,这要简单得多:
_model = (AudioPlayerPageViewModel)ViewModel;
哇哦!我们现在有了我们的安卓版本。
尝试运行项目,并放松一下,听听 Moby 最伟大的作品之一。
摘要
在本章中,我们使用Xamarin.iOS和Xamarin.Android在 iOS 和安卓上实现了音频功能。我们学习了如何通过开始、停止、播放、暂停、倒退和快进命令来加载、流式传输和处理音频。我们还使用 MVVM Cross 为原生应用构建了 MVVM 架构。在下一章中,我们将使用Xamarin.Forms构建一个用于吸收网络服务的应用程序。我们将设置一个ListView并创建一个ObservableCollection来显示 JSON 对象。
第五章. 构建股票列表应用程序
在本章中,我们回顾了Xamarin.Forms,并探讨了如何使用 CustomRenderers、Styles 和 ControlTemplates 来详细设计我们的 XAML 界面。我们还将探讨动画的使用以及复合动画的基本介绍。然后,我们将构建一个简单的 Web 服务,为我们的移动应用程序提供 JSON 数据流。
预期知识:
-
JSON 序列化/反序列化
-
一些关于 API 控制器的理解
-
Visual Studio
-
一些关于 Linq 查询的理解
-
一些关于 Observables 和 IObservables 的理解
-
一些关于 IIS 的知识
在本章中,你将学习以下内容:
-
理解后端
-
创建 ASP.Net Web API 2 项目
-
构建 API 控制器
-
设置移动项目
-
构建核心移动项目
-
提高应用性能
-
创建全局
App.xaml -
使用
ControlTemplates进行主题化 -
更新
MainPageViewModel -
创建股票列表 Web 服务控制器
-
ListViews和ObservableCollections -
值转换器
-
样式
-
使用 XAML 进行进一步优化
-
创建
StockItemDetailsPage -
自定义渲染器
-
为自定义元素添加样式
-
创建
StockItemDetailsPageViewModel -
设置本地平台项目
-
在本地托管 Web API 项目
理解后端
作为移动开发者,我们是客户端开发者。我们构建用户界面,并从 Web 服务中吸收 JSON 数据。在服务器和客户端同时开发的一个优势是能够根据移动应用程序的需求调整后端。这可以通过在 Web API 上进行数据事务来提高性能。如果我们必须基于一个旧的有问题的后端进行构建,构建快速且可靠的移动应用程序可能会很困难。如果用户遇到缓慢和不稳定的应用程序,他们通常永远不会再次使用它。
在这个例子中,我们将构建一个简单的 Web 服务,我们的移动应用程序将使用它。让我们首先打开 Visual Studio。
创建 ASP.Net Web API 2 项目
我们将从在 Microsoft Visual Studio 中创建一个新项目开始。转到文件 | 新建项目,然后选择一个新的 Visual C# ASP.Net 项目:

我们想选择Empty模板,然后点击Web API复选框。

我们实际上可以立即测试项目并点击运行,它将自动部署网站并在你的默认浏览器中运行应用程序。我们现在有了我们的基础ASP.NET应用程序模板,让我们更仔细地看看项目结构。在解决方案资源管理器中,从Models文件夹开始,这是我们创建所有代表应用程序中数据的对象的地方,这些对象将被序列化为 JSON 并通过 HTTP 请求发送。然后,在Controllers文件夹中,这是我们 API 控制器所在的地方,这些是处理 HTTP 请求的对象。这是我们将要关注的两个主要区域。
让我们从创建单个库存项目的数据模型开始。在Models文件夹中添加一个名为StockItem.cs的新文件:
public class StockItem
{
public int Id { get; set; }
public string Name { get; set; }
public string Category { get; set; }
public decimal Price { get; set; }
}
此对象将被序列化为 JSON 并通过我们的 API 控制器传递给移动应用程序以检索。通常,在每一个MVC / ASP.NET应用程序中,我们都有一个数据源层和一个 Web API 层。在我们的数据源层,这就是数据库所在的地方,我们在这里存储数据,我们的业务逻辑层将执行读取和写入。我们的 API 层通常使用业务逻辑层来访问数据并通过网络发送,可视表示如下:

构建 API 控制器
Web API 控制器用于处理 Web 请求。99%的情况下,移动应用程序总是会使用一个 API 层,它将调用 Web 请求来检索数据、执行登录等。在我们的例子中,我们将添加一个新的空WEBAPI 2控制器。

实现以下内容:
public class StockItemsController : ApiController
{
List<StockItem> StockItems = new List<StockItem>() {
new StockItem { Id = 1, Name = "Tomato Soup", Category = "Groceries", Price = 1 },
new StockItem { Id = 2, Name = "Yo-yo", Category = "Toys", Price = 3.75M },
new StockItem { Id = 3, Name = "Hammer", Category = "Hardware", Price = 16.99M }
};
public IEnumerable<StockItem> GetAllStockItems()
{
return StockItems;
}
public StockItem GetStockItem(int id)
{
var stockItem = StockItems.FirstOrDefault((p) => p.Id == id);
if (stockItem == null)
{
return null;
}
return StockItem;
}
}
仔细看看上面的代码,这个 API 有两个函数,一个用于返回所有库存项目,另一个用于返回特定的库存项目。如果我们想通过 HTTP 请求访问这个 API 控制器,URL 将会是:
- 获取所有库存项目
api/GetAllStockItems
- 通过 ID 获取特定的库存项目
api/GetStockItem
这个格式看起来熟悉吗?
我们将在我们的移动应用程序中使用这两个调用来检索后端的数据。
注意
为了让这个 API 活跃,我们有两种选择:我们可以将网站部署到线上(即使用 Azure 或 Amazon),或者我们可以本地托管它(使用 localhost)。
让我们测试 API 层并运行项目。当浏览器打开时,将以下 URL 粘贴到浏览器中:localhost:{PORT}/api/GetAllStockItems。
注意
当项目运行时,端口号将自动分配,所以请确保你粘贴了特定于你项目的正确端口号。
你应该看到一个 XML 显示器,显示 API 控制器中项目的结果。
设置移动项目
回到客户端,我们现在需要开始构建我们的移动应用程序。让我们从创建一个空的Xamarin.Forms应用程序开始:

将应用程序命名为 Stocklist,让我们从 iOS 应用程序开始。
构建核心移动项目
让我们添加两个新的 PCL 项目,命名为 Stocklist.XamForms 和 Stocklist.Portable。

在 Stocklist.Portable 项目中,我们想要添加以下 NuGet 包:
-
Microsoft HTTP 客户端库
-
Autofac
-
Newtonsoft.Json
-
反应式扩展(主要库)
在 Stocklist.XamForms 项目中,我们想要添加以下 NuGet 包:
-
Microsoft HTTP 客户端库
-
Autofac
-
Xamarin.Forms
-
反应式扩展(主要库)
小贴士
只需复制库的确切名称,通过包管理器工具调出所需的库。
现在我们已经准备好了项目,我们可以开始编码。从我们之前的解决方案 第三章,构建 GPS 定位器应用程序,我们想要重用一些主要部分,例如 IoC 容器、模块和跨平台导航。
小贴士
保持移动解决方案模块化和解耦,使得在不同解决方案之间共享代码变得更容易。你认为我们为什么有 NuGet 包?
就像我们的 Locator 应用程序一样,我们将重用 MainPage 和 MainPageViewModel 对象。将这些项目复制到您的新项目中,并将 XAML 页面放入 Stocklist.XamForms 中的新文件夹 Pages,并将视图模型对象放入 Stocklist.Portable 内的新文件夹 ViewModels 中。
提高应用性能
让我们看看我们可以通过哪些方式提高应用程序的性能。手机没有桌面处理器,用户通常在较旧的设备上运行您的应用程序,这意味着性能可能不足。这就是为什么我们必须在较旧和较新的设备上测试应用程序,以比较性能差异以及可能影响行为的任何 API/OS 变更。
小贴士
在模拟器上运行应用程序与在设备上运行时可能会得到不同的结果。确保在发布前始终在物理设备上进行测试。
让我们看看 Locator 项目的 MainPage.xaml 页面。在这里,我们将对 XAML 布局进行一些小的调整,以略微提高性能。这些更改非常微小,并且只会在这里和那里提高毫秒级的性能,但当你将 100 多个这样的小改进结合起来时,最终结果将会有所不同。
我们可以看到一个包含三个元素的 Grid,那么我们为什么选择 Grid 呢?Grid 适用于视图,我们用它来控制任何覆盖或覆盖放置位置的整个部分/页面。我们的第一个问题是是否需要覆盖整个屏幕以用于着陆页?不,我们不需要,因此我们可以用 StackLayout 替换 Grid。
一条规则:当 StackLayout 可以胜任时,不要使用 Grid,当 Grid 可以胜任时,不要使用多个 StackLayout。
当我们不需要覆盖整个屏幕或进行任何叠加时,一个 StackLayout 的渲染速度会比单个 Grid 快。让我们将包含的 Grid 替换为 StackLayout:
<StackLayout x:Name="StackLayout" Spacing="10" Orientation="Vertical" Padding="10, 10, 10, 10" VerticalOptions="Center">
<Label x:Name="DesciptionLabel" Text="{Binding DescriptionMessage}" HorizontalOptions="Center" Font="Arial, 20">
<Label.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Black"
WinPhone="Black"
iOS="Black">
</OnPlatform>
</Label.TextColor>
</Label>
<Button x:Name="StocklistButton" Text="{Binding LocationTitle}" Command="{Binding LocationCommand}" BackgroundColor="Silver">
<Button.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Black">
</OnPlatform>
</Button.TextColor>
</Button>
<Button x:Name="ExitButton" Text="{Binding ExitTitle}" Command="{Binding ExitCommand}" BackgroundColor="Silver">
<Button.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Black">
</OnPlatform>
</Button.TextColor>
</Button>
</StackLayout>
不要停下来,让我们再添加一些。关注 DescriptionLabel,为永远不会改变的静态文本值创建绑定是浪费的。相反,我们将使用 Spans,因为它们渲染得更快。首先,创建一个名为 LabelResources.resx 的新 .resx 文件,添加一个名为 DescriptionMessage 的新变量,并将值设置为字符串 欢迎来到杂货店:
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="DecriptionMessage" xml:space="preserve">
<value>Welcome to the Grocery Store</value>
</data>
</root>
注意
忽略第一个 data 标签之上的所有内容;当文件创建时,这将自动生成。
现在,让我们在我们的 MainPage 中导入 namespace 前缀:
将前面的行添加到页面的起始标签中:
<ContentPage
x:Class="Stocklist.XamForms.Pages.MainPage"
BackgroundColor="White">
现在,让我们重新构建标签项:
<Label x:Name="DesciptionLabel" HorizontalOptions="Center" >
<Label.FormattedText>
<FormattedString>
<Span Text="{x:Static resx:LabelResources.DecriptionMessage}"
FontFamily="Arial"
FontSize="24">
<Span.ForegroundColor>
<OnPlatform x:TypeArguments="Color"
Android="Black"
WinPhone="Black"
iOS="Black">
</OnPlatform>
</Span.ForegroundColor>
</Span>
</FormattedString>
</Label.FormattedText>
</Label>
仔细观察,我们有一个被 FormattedString 标签包围的 Span,而 FormattedString 标签则被包含在 Label.FormattedText 属性中。Span 正在从我们的新 LabelResources 中获取静态引用,并且我们也将 OnPlatform 的更改移动到了 Span 对象中(与标签完全相同,但不是使用 TextColor 属性,而是使用 Foreground 属性)。
这是对一个标签的两个微小改进,你可能不会在性能上注意到太大的差异。如果我们有一个包含大量静态标签的页面,它会在加载速度上产生微小的影响。渲染标签是昂贵的。
我们也可以将这些性能改进应用到按钮标题上。让我们移除两个按钮上 Text 属性的绑定,并用静态值替换它们。打开 LabelResources 文件,并按以下方式添加静态值:
<data name="ExitTitle" xml:space="preserve">
<value>Exit</value>
</data>
<data name="StocklistTitle" xml:space="preserve">
<value>Stock list</value>
</data>
然后我们将它应用到按钮的属性上:
Text="{x:Static resx:LabelResources.StocklistTitle}"
Text="{x:Static resx:LabelResources.ExitTitle}"
为了完成着陆页,让我们在按钮上方添加一个图像:
<Image x:Name="Image" Source="stocklist.png" IsOpaque="true" HeightRequest="120" WidthRequest="120"/>
注意
所有图像文件都可以从 GitHub 链接获取:github.com/flusharcade/chapter5-stocklist。
由于这个图像是不透明的,所以 IsOpaque 属性被标记为 true。将此属性设置为 true 允许进行另一个小的性能提升。透明图像渲染成本较高。
我们对页面最后的补充是将页面的标题设置为来自我们的 LabelResources 的另一个静态值。添加一个名为 WelcomeTitle 的新值:
<data name="WelcomeTitle" xml:space="preserve">
<value>Welcome</value>
</data>
现在,让我们将其添加到 MainPage 的启动标志中:
Title="{x:Static resx:LabelResources.WelcomeTitle}"
我们完成的实现将如下所示:
<StackLayout x:Name="StackLayout" Spacing="10" Orientation="Vertical" Padding="10, 10, 10, 10" VerticalOptions="Center" HorizontalOptions="Center" >
<Image x:Name="Image" Source="stocklist.png" IsOpaque="true" HeightRequest="120" WidthRequest="120"/>
<Label x:Name="DesciptionLabel" >
<Label.FormattedText>
<FormattedString>
<Span Text="{x:Static resx:LabelResources.DecriptionMessage}"
FontFamily="Arial"
FontSize="24">
<Span.ForegroundColor>
<OnPlatform x:TypeArguments="Color"
Android="Black"
WinPhone="Black"
iOS="Black">
</OnPlatform>
</Span.ForegroundColor>
</Span>
</FormattedString>
</Label.FormattedText>
</Label>
<Button x:Name="StocklistButton" Text="{x:Static resx:LabelResources.StocklistTitle}" Command="{Binding StocklistCommand}" BackgroundColor="Silver">
<Button.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Black">
</OnPlatform>
</Button.TextColor>
</Button>
<Button x:Name="ExitButton" Text="{x:Static resx:LabelResources.ExitTitle}" Command="{Binding ExitCommand}" BackgroundColor="Silver">
<Button.TextColor>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Black">
</OnPlatform>
</Button.TextColor>
</Button>
</StackLayout>
让我们回顾一下我们对这个 ContentPage 做的小改动:
-
当 Grid 可以胜任时,不要使用
StackLayout -
不要使用多个 StackLayout,使用 Grid
-
在可能的情况下,用静态值替换绑定
-
当图像不透明时,将
IsOpaque标志设置为true -
在具有静态标签值的标签上使用
FormattedText和Span
我们可以应用更多的改进,你的应用程序将运行得更快。我们将在后续的项目中查看更多的改进。
创建一个全局的 App.xaml
在所有 Xamarin.Forms 项目中,我们必须创建一个继承自 Application 类的 Application 文件。我们将扩展这个 Application 文件并创建一个全局资源字典。如果你来自 WPF,你会认识到全局资源字典的使用,我们可以在所有的 XAML 表格中引用它。这个全局资源字典保存在 App.xaml 文件中。它将包含对不同的转换器、样式和数据模板的引用。我们不想在每个 ContentPage 或 ContentView 的顶部声明静态资源字典,我们只想创建一个所有 XAML 接口都可以访问的字典。这意味着我们只在应用程序启动时创建一个字典,而不是在视图显示时创建多个字典。
让我们创建一个新的 ContentPage,命名为 App.xaml,并将其放置在 Stocklist.XamForms 项目中。我们现在可以删除该项目中已经存在的 App.cs 文件。在 App.xaml 文件中,实现以下内容:
<Application
x:Class="Stocklist.XamForms.App">
<Application.Resources>
<ResourceDictionary>
</ResourceDictionary>
</Application.Resources>
</Application>
我们使用 XAML 声明一个 Application 对象,并在应用程序的资源部分创建全局字典。我们还需要打开 App.xaml.cs 文件并初始化组件(与 ContentPage 和 ContentView 的初始化完全相同),资源字典,以及 MainPage 对象:
public partial class App : Application
{
public App()
{
this.InitializeComponent();
// The Application ResourceDictionary is available in Xamarin.Forms 1.3 and later
if (Application.Current.Resources == null)
{
Application.Current.Resources = new ResourceDictionary();
}
this.MainPage = IoC.Resolve<NavigationPage>();
}
protected override void OnStart()
{
// Handle when your app starts
}
protected override void OnSleep()
{
// Handle when your app sleeps
}
protected override void OnResume()
{
// Handle when your app resumes
}
}
我们在解析 NavigationPage 之前忘记做某件事了吗?
我们必须将我们的 XamForms 模块添加到 IoC 容器中。首先,让我们重用 Locator 项目中的导航设置。创建一个名为 UI 的新文件夹,并将以下文件从 Locator 应用程序中的 Xamarin.Forms 项目复制过来:
-
INavigableXamarinFormsPage.cs -
NavigationService.cs -
XamarinNavigationExtensions.cs
我们需要将每个文件中的命名空间从 Locator.UI 更改为 Stocklist.XamForms.UI,并在 GetPage 函数中对 PageNames 枚举进行修改:
private Page GetPage(PageNames page)
{
switch(page)
{
case PageNames.MainPage:
return IoC.Resolve<MainPage> ();
case PageNames.StocklistPage:
return IoC.Resolve<Func<StocklistPage>>()();
default:
return null;
}
}
太好了!我们现在已经有了导航服务,让我们将其注册到 XamFormsModule。在 Stocklist.XamForms 项目中创建一个新文件夹,为 XamFormsModule 添加一个新文件,实现以下内容:
public class XamFormsModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<MainPage> ().SingleInstance();
builer.RegisterType<StocklistPage> ().SingleInstance();
builer.RegisterType<Xamarin.Forms.Command> ().As<ICommand>().SingleInstance();
builer.Register (x => new NavigationPage(x.Resolve<MainPage>())).AsSelf().SingleInstance();
builer.RegisterType<NavigationService> ().As<INavigationService>().SingleInstance();
}
}
现在我们已经注册了 XamFormsModule,我们可以解析 NavigationPage 和 NavigationService。
让我们开始构建将包含在全局资源字典中的项目。
使用 ControlTemplates 进行主题化
ControlTemplates 允许将逻辑视图层次结构与视觉层次结构分离。类似于 DataTemplate,ControlTemplate 将为你的控制器页面生成视觉层次结构。ControlTemplates 的一个优点是主题的概念。许多软件应用程序提供更改用户界面样式的设置(Visual Studio 和 Xamarin Studio 提供暗色和亮色主题)。我们将为 MainPage 实现两个主题,并提供一个 Button 来在两者之间切换。
让我们从打开App.xaml页面并添加黑色主题的第一个ControlTemplate开始:
<ControlTemplate x:Key="MainBlackTemplate">
<StackLayout x:Name="StackLayout" Spacing="10" Orientation="Vertical" Padding="10, 10, 10, 10" BackgroundColor="Black"
VerticalOptions="Center" HorizontalOptions="Center" >
<Image x:Name="Image" Source="stocklist.png" HeightRequest="120" WidthRequest="120"/>
<Label x:Name="DesciptionLabel">
<Label.FormattedText>
<FormattedString>
<Span Text="{x:Static resx:LabelResources.DecriptionMessage}"
FontFamily="Arial"
FontSize="24"
ForegroundColor="White"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Button x:Name="StocklistButton"
Text="{x:Static resx:LabelResources.StocklistTitle}"
Command="{TemplateBinding StocklistCommand}"
Style="{StaticResource HomeButtonStyle}"
BackgroundColor="Gray"
TextColor="White"/>
<Button x:Name="ExitButton"
Text="{x:Static resx:LabelResources.ExitTitle}"
Command="{TemplateBinding ExitCommand}"
Style="{StaticResource HomeButtonStyle}"
BackgroundColor="Gray"
TextColor="White"/>
<ContentPresenter />
</StackLayout>
</ControlTemplate>
在这里,我们只是复制MainPage的内容,并根据模板的变化进行微小的颜色更改。
现在让我们为白色主题添加另一个ControlTemplate:
<ControlTemplate x:Key="MainWhiteTemplate">
<StackLayout x:Name="StackLayout" Spacing="10" Orientation="Vertical" Padding="10, 10, 10, 10" VerticalOptions="Center" HorizontalOptions="Center" >
<Image x:Name="Image" Source="stocklist.png" HeightRequest="120" WidthRequest="120"/>
<Label x:Name="DesciptionLabel" >
<Label.FormattedText>
<FormattedString>
<Span Text="{x:Static resx:LabelResources.DecriptionMessage}"
FontFamily="Arial"
FontSize="24"
ForegroundColor="Black"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Button x:Name="StocklistButton"
Text="{x:Static resx:LabelResources.StocklistTitle}"
Command="{TemplateBinding StocklistCommand}"
Style="{StaticResource HomeButtonStyle}"/>
<Button x:Name="ExitButton"
Text="{x:Static resx:LabelResources.ExitTitle}"
Command="{TemplateBinding ExitCommand}"
Style="{StaticResource HomeButtonStyle}"/>
<ContentPresenter />
</StackLayout>
</ControlTemplate>
注意每个模板中ContentPresenter对象的使用?
这用于定位将在多个模板之间共享的内容。打开MainPage.xaml,并将内容替换为以下内容:
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
x:Class="Stocklist.XamForms.Pages.MainPage"
ControlTemplate="{StaticResource MainBlackTemplate}"
BackgroundColor="Black"
Title="{x:Static resx:LabelResources.WelcomeTitle}"
StocklistCommand="{Binding StocklistCommand}"
ExitCommand="{Binding ExitCommand}">
<ContentPage.Content>
<Button Text="Change Theme" Clicked="ChangeThemeClicked" />
</ContentPage.Content>
</ContentPage>
放置在MainPage上的内容将位于ControlTemplates中ContentPresenter对象所在的位置。内容只是一个将在两个ControlTemplates之间共享的按钮。我们将首先设置默认的ControlTemplate为黑色主题。
注意在ContentPage上设置的命令绑定?
由于我们的ControlTemplates需要绑定到MainPageViewModel中的Commands,我们必须做一些额外的工作来设置这些绑定。打开MainPage.xaml.cs并实现这些自定义绑定:
public static readonly BindableProperty StocklistCommandProperty = BindableProperty.Create("StocklistCommand", typeof(ICommand), typeof(MainPage), null);
public static readonly BindableProperty ExitCommandProperty = BindableProperty.Create("ExitCommand", typeof(ICommand), typeof(MainPage), null);
public ICommand StocklistCommand
{
get { return (ICommand)GetValue(StocklistCommandProperty); }
}
public ICommand ExitCommand
{
get { return (ICommand)GetValue(ExitCommandProperty); }
}
这些自定义绑定将设置每个ControlTemplate和视图模型之间的链接。现在,ControlTemplate内的每个Command都将对视图模型中实现的Command做出响应。
现在让我们完成更改主题的添加。首先,让我们添加两个模板定义:
private bool _originalTemplate = true;
private ControlTemplate _blackTemplate;
private ControlTemplate _whiteTemplate;
originalTemplate Boolean用作每次按钮点击时切换到相反模板的标志。接下来,我们必须从我们的全局资源字典中初始化ControlTemplate:
public MainPage()
{
InitializeComponent();
_blackTemplate = (ControlTemplate)Application.Current.Resources["MainBlackTemplate"];
_whiteTemplate = (ControlTemplate)Application.Current.Resources["MainWhiteTemplate"];
}
Finally, let's add the ChangeThemeClicked function for the button:
public void ChangeThemeClicked(object sender, EventArgs e)
{
_originalTemplate = !_originalTemplate;
ControlTemplate = _originalTemplate ? _blackTemplate : _whiteTemplate;
BackgroundColor = _originalTemplate ? Color.Black : Color.White;
}
每次按钮被按下时,它将检查我们是否在默认模板(黑色主题)上,如果我们在黑色模板上,则切换到白色模板。我们还将背景颜色在黑色和白色之间切换,以匹配当前主题。
完成了。现在让我们转到MainPageViewModel以完成页面的BindingContext。
更新 MainPageViewModel
现在我们已经重新构建了我们的MainPage,让我们对MainPageViewModel做一些小的更改。由于我们用静态值替换了标签绑定,我们移除了以下变量,DescriptionMessage、ExitTitle和LocationTitle。
现在我们应该有以下private属性:
#region Private Properties
private readonly IMethods _methods;
private ICommand _stocklistCommand;
private ICommand _exitCommand;
#endregion
现在只需更新LocationCommand为以下内容:
public ICommand StocklistCommand
{
get
{
return stocklistCommand;
}
set
{
if (value.Equals(stocklistCommand))
{
return;
}
_stocklistCommand = value;
OnPropertyChanged("StocklistCommand");
}
}
我们还必须更新我们的构造函数:
#region Constructors
public MainPageViewModel (INavigationService navigation, Func<Action, ICommand> commandFactory,
IMethods methods) : base (navigation)
{
this.exitCommand = commandFactory (() => methods.Exit());
this.stocklistCommand = commandFactory (async () => await this.Navigation.Navigate(PageNames.StocklistPage, null));
}
#endregion
在这里,我们只是将一些变量重命名以匹配我们的应用程序。我们还必须复制Enums和Extras文件夹,并将LocationPage枚举更改为StocklistPage。
接下来,我们需要添加PortableModule。创建一个名为Modules的新文件夹,并将Location.Portable中的PortableModule复制到新文件夹中。将PortableModule类更改为以下内容:
public class PortableModule : IModule
{
public void Register(ContainerBuilder builer)
{
builer.RegisterType<MainPageViewModel> ().SingleInstance();
builer.RegisterType<StocklistPageViewModel> ().SingleInstance();
}
}
最后,我们需要添加INavigationService。创建一个名为UI的新文件夹,并将Location.Portable中的INavigationService添加到新的UI文件夹中。
提示
构建项目模板可以减少设置项目和重新创建类似模块所需的时间。

在我们继续之前,我们必须更新从 Locator 项目复制的代码中的命名空间。最简单的方法是使用 搜索 | 在文件中替换...。我们想要将文本 Location.Portable 替换为文本。
注意
做这件事时要小心;只有当字符串是特定的时候才应用全局替换。
创建 Stocklist Web 服务控制器
让我们构建我们的客户端 Web 服务控制器以访问 API。由于我们已经构建了后端,我们应该能够非常快速地完成这项工作。我们的第一步是创建一个对象,该对象将反序列化 StockItem。我们把这些称为合约。在你的 Stocklist.Portable 项目中添加一个新的文件夹,命名为 StocklistWebServiceController,然后在其中添加另一个文件夹,命名为 Contracts。创建一个名为 StockItemContract.cs 的新文件,并实现以下内容:
public sealed class StockItemContract
{
#region Public Properties
public int Id { get; set;}
public string Name { get; set; }
public string Category { get; set; }
public decimal Price { get; set; }
#endregion
}
现在,让我们继续构建 IStocklistWebServiceController 接口:
public interface IStocklistWebServiceController
{
#region Methods and Operators
IObservable<StockItemContract> GetAllStockItems ();
Task<StockItemContract> GetStockItemById(int id);
#endregion
}
这些函数与 API 控制器中的函数完全匹配。在我们实现此接口之前,我们必须在 Resources 文件夹中创建一个新的文件,命名为 Config.resx。目前,让我们为每个 URL 路径添加一些空值,因为我们不知道这些信息,除非我们在本地运行站点,或者将其部署到某个地方:
<data name="ApiAllItems" xml:space="preserve">
<value></value>
</data>
<data name="GetStockItem" xml:space="preserve">
<value></value>
</data>
现在,让我们实现 IStocklistWebServiceController 接口。从构造函数开始;我们不得不检索 HttpClientHandler(我们将在稍后的 IoC 容器中注册它):
#region Constructors and Destructors
public StocklistWebServiceController(HttpClientHandler clientHandler)
{
_clientHandler = clientHandler;
}
#endregion
现在,让我们实现第一个函数来检索所有项目。它将使用 HttpClient 通过 SendAsync 异步函数创建一个 Observable,然后通过 HttpClient。Observable 流将从这个函数返回的结果生成。然后我们将响应作为字符串检索(这将是一个 JSON),并将字符串反序列化为多个 StockItemContracts,然后(使用 Linq)将它们传递到 Observable 流中,并返回函数的结果:
public IObservable<StockItemContract> GetAllStockItems ()
{
var authClient = new HttpClient (this.clientHandler);
var message = new HttpRequestMessage (HttpMethod.Get, new Uri (Config.ApiAllItems));
return Observable.FromAsync(() => authClient.SendAsync (message, new CancellationToken(false)))
.SelectMany(async response =>
{
if (response.StatusCode != HttpStatusCode.OK)
{
throw new Exception("Respone error");
}
return await response.Content.ReadAsStringAsync();
})
.Select(json => JsonConvert.DeserializeObject<StockItemContract>(json));
}
现在让我们来实现 GetStockItem 函数:
public IObservable<StockItemContract> GetStockItem (int id)
{
var authClient = new HttpClient(this.clientHandler);
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(string.Format(Config.GetStockItem, id)));
return await Observable.FromAsync(() => authClient.SendAsync(message, new CancellationToken(false)))
.SelectMany(async response =>
{
if (response.StatusCode != HttpStatusCode.OK)
{
throw new Exception("Respone error");
}
return await response.Content.ReadAsStringAsync();
})
.Select(json => JsonConvert.DeserializeObject<StockItemContract>(json));
}
太好了!我们现在有了 StocklistWebServiceController;我们现在需要将此对象注册到 IoC 容器中的接口。打开 PortableModule 类,并添加以下内容:
builer.RegisterType<StocklistWebServiceController> ().As<IStocklistWebServiceController>().SingleInstance();
列表视图和 ObservableCollections
现在我们转向StocklistPage和StocklistPageViewModel;这些将用于显示我们从 API 拉取的所有项目。在前端,我们将使用ListView,它们是显示从任何 API 拉取的数据列表的最常见 UI 元素。ListView的美丽之处在于它们如何通过每个平台呈现。通过Xamarin.Forms在 iOS 上的 XAML 表单中放置一个ListView将渲染一个UITableView,在 Android 上是一个原生的ListView,而在 Windows 上是一个FrameworkElement。我们还可以创建自定义单元格项并设置针对每个项目的特定数据绑定,因此,对于每个反序列化的合同,我们希望有一个单独的视图模型来表示每个单元格上的数据。
让我们在Stocklist.Portable项目的ViewModels文件夹中添加一个名为StockItemViewModel.cs的新文件,并实现其构造函数:
public class StockItemViewModel : ViewModelBase
{
#region Constructors
public StockItemViewModel (INavigationService navigation) : base (navigation)
{
}
#endregion
}
现在我们想要添加private属性;它们将与StockItemContract中的属性相同:
注意
我们可以选择只在一个自定义视图单元格中表示某些项目。在视图模型内部,我们只创建将在视图中显示的属性。
#region Private Properties
private int _id;
private string _name;
private string _category;
private decimal _price;
private bool _inProgress;
#endregion
然后我们只需为每个private变量创建相应的public属性,以下是一个帮助你开始的例子:
public int Id
{
get
{
return id;
}
set
{
if (value.Equals(_id))
{
return;
}
_id = value;
OnPropertyChanged("Id");
}
}
在这里,我们正在构建一个翻译层,在反序列化的对象和我们要显示的对象之间。这对于分离视图模型中包含的逻辑是有好处的,因为它们有额外的逻辑来处理要显示的数据。我们希望我们的合同纯粹反映 JSON 对象中的属性。
接下来,我们在视图模型上添加一个名为Apply的公共方法。这个方法将接受一个StockItemContract作为参数并更新视图模型的属性。它将在我们想要更新要显示的数据时被调用:
#region Public Methods
public void Apply(StockItemContract contract)
{
Id = contract.Id;
Name = contract.Name;
Category = contract.Category;
Price = contract.Price;
}
#endregion
我们下一步是实现StocklistPageViewModel。这个视图模型将包含一个ObservableCollection,它将被用于绑定到ListView。在我们检索到合同列表后,我们将构建另一个StockItemViewModels列表。每个项目将应用合同中的数据,新的StockItemViewModel将被添加到ObservableCollection中。我们将应用合同来更新数据,然后将视图模型添加到ObservableCollection。
让我们从向ViewModels文件夹添加一个名为StocklistPageViewModel.cs的新文件开始,并首先创建一个新的视图模型及其构造函数:
#region Constructors
public StocklistPageViewModel(INavigationService navigation, IStocklistWebServiceController stocklistWebServiceController,
Func<StockItemViewModel> stockItemFactory) : base(navigation)
{
_stockItemFactory = stockItemFactory;
_stocklistWebServiceController = stocklistWebServiceController;
StockItems = new ObservableCollection<StockItemViewModel>();
}
#endregion
导航服务与Locator项目中使用的相同。我们将在Stocklist.XamForms项目中稍后注册它。我们使用IStocklistWebServiceController从 API 获取StockItems。
然后,我们需要在PortableModule内部注册我们的StockItemViewModel:
public void Register(ContainerBuilder builer)
{
...
builer.RegisterType<StockItemViewModel>().InstancePerDependency();
}
注意我们是如何使用 InstancePerDependency 函数而不是 SingleInstance 的?由于我们正在实例化多个项,如果我们使用 SingleInstance,相同的数据将在所有 StockItemViewModels 之间复制并更改。
现在让我们添加 private 和 public 属性:
#region Private Properties
private readonly IStocklistWebServiceController _stocklistWebServiceController ;
private readonly Func<StockItemViewModel> _stockItemFactory;
#endregion
#region Public Properties
public ObservableCollection<StockItemViewModel> StockItems;
#endregion
现在我们有了所有属性,我们可以构建 ObservableCollection 的项目列表。接下来,我们添加 LoadAsync 函数,它负责创建 StockItemViewModels 的列表:
#region Methods
protected override async Task LoadAsync(IDictionary<string, object> parameters)
{
try
{
InProgress = true;
// reset the list everytime we load the page
StockItems.Clear();
var stockItems = await _stocklistWebServiceController.GetAllStockItems();
// for all contracts build stock item view model and add to the observable collection
foreach (var model in stockItems.Select(x =>
{
var model = _stockItemFactory();
model.Apply(x);
return model;
}))
{
StockItems.Add(model);
}
InProgress = false;
}
catch (Exception e)
{
System.Diagnostics.Debug.WriteLine(e);
}
}
#endregion
LoadAsync 函数将用于检索所有合同并构建 StockItemViewModels 的列表。每次我们将新的 StockItemViewModel 添加到 ObservableCollection 时,都会触发一个 CollectionChanged 事件来通知 ListView 更新。
看看我们是如何通过 stockItemfactory 实例化 StockItemViewModel 的。它使用 Func (Func<StockItemViewModel>) 在每次执行 Func 时生成一个新的视图模型。这就是为什么我们需要调用 InstancePerDependency,以便创建独立的项。如果我们将注册上的结束函数设置为 SingleInstance,即使我们在 StockItemViewModel 上调用 Func,也只会创建一个对象。
现在让我们构建 StocklistPage 的用户界面。它将包含用于显示从 API 获取的 StockItems 的 ListView:
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
x:Class="Stocklist.XamForms.Pages.StocklistPage">
<ContentPage.Content>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ActivityIndicator x:Name="ActivityIndicator" IsRunning="{Binding InProgress}" Grid.Row="0" Grid.Column="0"/>
<ListView x:Name="StockItemsListView"
IsVisible="{Binding InProgress, Converter={StaticResource notConverter}}"
CachingStrategy="RecycleElement"
ItemsSource="{Binding StockItems}"
ItemTemplate="{StaticResource ListItemTemplate}"
SelectedItem="{Binding Selected, Mode=TwoWay}"
RowHeight="100"
Margin="10, 10, 10, 10"
Grid.Row="0" Grid.Column="0"/>
</Grid>
</ContentPage.Content>
</ContentPage>
为什么我们不能使用 StackLayout?
由于我们需要一个元素覆盖另一个元素,我们必须使用 Grid。ActivityIndicator 用于显示 LoadAsync 函数的加载进度。当正在加载时,我们的 ListView 将不可见,并显示加载指示器。
值转换器
在某些情况下,有时我们需要将不兼容类型的两个属性进行数据绑定。Converter 是一个对象,它将值从源转换为目标,反之亦然。每个转换器都必须实现 IValueConverter 接口,该接口实现了两个函数,Convert 和 ConvertBack。我们将创建一个转换器,它将 bool 作为源,并简单地返回与源值相反的值。
ConvertBack 方法仅在数据绑定是 TwoWay 绑定时才会使用。
在 Stocklist.XamForms 项目中,添加一个名为 Converters 的新文件夹,并在该文件夹内创建一个名为 NotConverter.cs 的新文件,实现以下内容:
public class NotConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
var b = value as bool?;
if (b != null)
{
return !b;
}
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
即使 InProgress 属性不使用双向绑定,我们仍然必须实现 ConvertBack 函数作为接口的一部分。
现在回到 StocklistPage.xaml。当视图模型中的 bool 属性发生变化时,NotConverter 的 Convert 函数将被调用。当 IsProgress 值发生变化时,转换器将被调用,并将返回 ListView 上 IsVisible 状态的反向值。当进度正在运行时,ListView 不可见,当进度未运行时,ListView 可见。
现在我们将探讨创建一个包含每个单元格使用的DataTemplate的App.xaml。
将DataTemplate添加到全局资源字典
现在,让我们回到App.xaml文件。由于我们在StocklistPage的ListView中需要一个自定义单元格,我们将在全局资源字典中创建一个DataTemplate。DataTemplate可以以两种方式创建,作为内联模板或在资源字典中。没有更好的方法,这更多是个人偏好的问题。在我们的例子中,我们将创建资源字典中的我们的DataTemplate。
打开App.xaml文件,并按如下方式在资源字典中插入DataTemplate:
<DataTemplate x:Key="ListItemTemplate">
<ViewCell>
<StackLayout Margin="20, 15, 20, 5">
<Label x:Name="NameLabel" Text="{Binding Name}"/>
<Label x:Name="CategoryLabel" Text="{Binding Category}"/>
<Label x:Name="PriceLabel" Text="{Binding Price}"/>
</StackLayout>
</ViewCell>
</DataTemplate>
现在,我们想在StocklistPage中的ListView上设置ItemTemplate属性。打开StocklistPage,并将以下内容添加到ListView声明中:
<ListView x:Name="StockItemsListView" ItemsSource="{Binding StockItems}" ItemTemplate="{StaticResource ListItemTemplate}"/>
如果我们想使用内联模板方法,我们会这样做:
<ListView x:Name="StockItemsListView" ItemsSource="{Binding StockItems">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<StackLayout Margin="20, 15, 20, 5">
<Label x:Name="NameLabel" Text="{Binding Name/>
<Label x:Name="CategoryLabel" Text="{Binding Category}"/>
<Label x:Name="PriceLabel" Text="{Binding Price}"/>
</StackLayout>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
样式
在我们的自定义单元格中,我们有三个标签,没有任何样式或字体分配。我们将使用Style来增强每个单元格的外观。Style将一组属性值组合成一个对象,可以应用于多个视觉元素实例。这种做法的目的是减少重复的标记,以便我们可以在 XAML 中跨相似控件重用类似的样式。在Xamarin.Forms中,有多种方法可以将样式应用于控件。在这个例子中,我们将向您展示如何在App.xaml文件中创建全局样式,并将其应用于我们应用程序中的不同控件。
我们的第一个全局样式将是自定义单元格中的标题标签。让我们打开App.xaml文件,并将以下内容插入到我们的资源字典中:
<Style x:Key="TitleStyle" TargetType="Label">
<Setter Property="TextColor" Value="Black" />
<Setter Property="FontAttributes" Value="Bold" />
<Setter Property="FontFamily" Value="Arial" />
</Style>
在前面的标记中,每个样式将包含一个Setter属性的列表。这些指的是我们控件上的BindableProperties。现在我们有了Style,我们可以在DataTemplate内部引用这个静态资源:
<Label x:Name="NameLabel" Text="{Binding Name}" Style="{StaticResource TitleStyle}"/>
太好了!我们刚刚在Label上创建并设置了我们的第一个样式。让我们向MainPageControlTemplates添加更多样式。我们将对按钮进行样式化,因为它们都共享相同的样式属性。将以下内容添加到全局资源字典中:
<Style x:Key="HomeButtonStyle" TargetType="Button">
<Setter Property="TextColor">
<Setter.Value>
<OnPlatform x:TypeArguments="Color"
Android="Navy"
WinPhone="Blue"
iOS="Black">
</OnPlatform>
</Setter.Value>
</Setter>
<Setter Property="BackgroundColor" Value="Silver" />
</Style>
仔细看看前面的样式,我们甚至可以使用<OnPlatform>标签根据平台更改设置值。
现在,让我们将这个Style应用到我们的MainPage按钮上:
<Button x:Name="StocklistButton"
Text="{x:Static resx:LabelResources.StocklistTitle}"
Command="{Binding StocklistCommand}"
Style="{StaticResource HomeButtonStyle}"/>
<Button x:Name="ExitButton"
Text="{x:Static resx:LabelResources.ExitTitle}"
Command="{Binding ExitCommand}"
Style="{StaticResource HomeButtonStyle}"/>
看看我们是如何减少标记大小的?
这是我们应用Styles的一个例子,我们将在后续章节中看到更多技术。
使用 XAML 进行进一步优化
之前,我们讨论了一些我们可以应用于 XAML 的微小更改,以改进性能。让我们看看我们如何在ListView上应用一些性能提升。如果您使用过任何本地的ListView或UITableView,最大的问题之一就是我们滚动时加载大量元素时的内存使用(即,为每个单元格加载一个位图)。
我们如何解决这个问题?
我们使用缓存单元格和重用单元格的技术。自从 Xamarin.Forms 2.0 以来,他们引入了一些关于 ListViews 上的单元格回收机制和缓存策略的新功能和增强。为了设置缓存策略,我们有两种选择:
-
RetainElement: 这是默认行为。它将为列表中的每个项目生成一个单元格,单元格布局将在每个单元格创建时运行。我们只有在单元格布局经常变化,或者一个单元格有大量绑定时才应使用此方法。 -
RecycleElement: 这利用了 iOS 和 Android 上的原生单元格回收机制。它将最小化内存占用并最大化ListView的性能。如果单元格有少量到中等的绑定,布局相似,并且单元格视图模型包含所有数据,我们应该使用此方法。
小贴士
我们应该始终致力于使用第二个元素,尽量设计你的单元格围绕这个设置。
我们将在我们的 ListView 上使用第二种缓存策略:
<ListView x:Name="StockItemsListView" CachingStrategy="RecycleElement" ItemsSource="{Binding StockItems}" ItemTemplate="{StaticResource ListItemTemplate}"/>
应尽可能多地使用 RecycleElement,因为我们总是希望根据我们的应用程序来最大化性能。由于我们的单元格设计相当简单,绑定数量少,并且我们保持所有数据在视图模型中,我们能够使用此设置。
现在,让我们看看另一个我们可以用来提高我们的 XAML 表格加载速度的简单添加。开启 XAML 编译功能可以让你的 XAML 表格被编译而不是被解释,这可以提供多种好处:
-
帮助标记错误
-
减小应用程序大小
-
减少加载和实例化时间
非常推荐在所有 Xamarin.Forms 应用程序中启用此设置,因为它将提高你用户界面的加载速度(特别是在 Android 上)。我们可以通过打开 App.xaml.cs 文件并将以下代码粘贴在先前的命名空间下面来添加编译后的 XAML:
[assembly: Xamarin.Forms.Xaml.XamlCompilation(Xamarin.Forms.Xaml.XamlCompilationOptions.Compile)]
如果我们将我们对项目应用的所有性能改进加起来,我们应该会在用户界面在不同屏幕之间呈现时看到一些改进。
创建 StockItemDetailsPage
现在,我们继续到我们应用程序的最后一页。我们将为显示从上一个 StocklistPage 中选择的股票项目的详细信息添加另一个页面。首先,我们需要处理从 ListView 中选择的项,所以打开 StocklistPage.xaml 并更新 ListView 元素,将其 SelectedItem 对象绑定到我们的视图模型中的 Selected 项(我们将在 XAML 更新后添加此绑定)。这将设置为 TwoWay 绑定,因为数据将从两边改变(从视图,因为我们选择了项,以及视图模型,因为我们将在导航到股票详情页时需要选择对象的数据):
<ListView x:Name="StockItemsListView"
IsVisible="{Binding InProgress, Converter={StaticResource notConverter}}"
CachingStrategy="RecycleElement"
ItemsSource="{Binding StockItems}"
ItemTemplate="{StaticResource ListItemTemplate}"
SelectedItem="{Binding Selected, Mode=TwoWay}"
RowHeight="100"
Margin="10, 10, 10, 10"
Grid.Row="0" Grid.Column="0"/>
现在让我们添加到StocklistPageViewModel;我们需要添加一个public的StockItemViewModel属性,它将保存从列表中选择项目时的绑定数据。所选StockItemViewModel的 ID 属性将通过导航参数传递给我们的StockItemDetailsPage:
private StockItemViewModel _selected;
....
public StockItemViewModel Selected
{
get
{
return _selected;
}
set
{
if (value.Equals(_selected))
{
return;
}
else
{
Navigation.Navigate(Enums.PageNames.StockItemDetailsPage, new Dictionary<string, object>()
{
{"id", value.Id},
}).ConfigureAwait(false);
}
_selected = value;
OnPropertyChanged("Selected");
}
}
现在让我们添加新的StocklistItemDetailsPage。创建一个新的 XAML ContentPage并添加以下内容:
<ContentPage.Content>
<StackLayout Margin="20, 20, 20, 5">
<Label x:Name="TitleLabel" >
<Label.FormattedText>
<FormattedString>
<Span Text="{x:Static resx:LabelResources.StockItemDetailsTitle}"
FontFamily="Arial"
FontSize="24">
<Span.ForegroundColor>
<OnPlatform x:TypeArguments="Color"
Android="Black"
WinPhone="Black"
iOS="Black">
</OnPlatform>
</Span.ForegroundColor>
</Span>
</FormattedString>
</Label.FormattedText>
</Label>
<Label x:Name="NameLabel" Text="{Binding Name}" Style="{StaticResource TitleStyle}"/>
<controls:CustomLabel x:Name="CategoryLabel" Text="{Binding Category}" Style="{StaticResource CustomLabelStyle}"/>
<controls:CustomLabel x:Name="PriceLabel" Text="{Binding Price}" Style="{StaticResource CustomLabelStyle}"/>
<ActivityIndicator x:Name="ActivityIndicator" IsRunning="{Binding InProgress}"/>
</StackLayout>
</ContentPage.Content>
仔细查看代码,我们添加了四个标签和ActivityIndicator,它们用于显示页面加载数据的进度。我们还包含了一个自定义控件CustomLabel,我们通过以下方式引用此项目:
无论跟随xmlns关键字之后的名称是什么,这个名称必须首先被调用,以便在我们要使用的命名空间内引用项目,如下所示:
<controls:CustomLabel/>
现在我们必须创建我们的CustomLabel对象,它将被用于 Android 的CustomRenderer,因为我们将设置此标签的字体为自定义的Typeface,我们将将其包含在两个原生项目中。在Stocklist.XamForms项目中创建一个名为Controls的新文件夹,并创建以下文件,名为CustomLabel.cs:
public class CustomLabel : Label
{
public static readonly BindableProperty AndroidFontStyleProperty = BindableProperty.Create<CustomLabel, string>(
p => p.AndroidFontStyle, default(string));
public string AndroidFontStyle
{
get
{
return (string)GetValue(AndroidFontStyleProperty);
}
set
{
SetValue(AndroidFontStyleProperty, value);
}
}
}
在我们的CustomLabel中,我们添加了一个自定义绑定,这个绑定将专门用于为 Android 设置字体样式。当我们设置原生侧的字体样式时,我们必须通过文件名而不是字体名来设置自定义字体,而在 iOS 中,我们通过字体名而不是文件名来引用自定义字体。
当我们设置自定义绑定时,我们必须始终包含一个static属性,这是我们所说的BindableProperty,它用于引用我们正在绑定的 UI 元素的项。然后我们必须始终包含在 XAML 中引用的实际属性:
<controls:CustomLabel AndroidFontStyle="GraCoRg_" />
自定义渲染器
你会发现Xamarin.Forms通过跨平台元素(如 XAML 对象)覆盖了大多数原生控件,但有一些 UI 元素我们必须自己使用CustomRenderers来实现。CustomRenderers允许任何人覆盖我们 XAML 中特定控件的重绘过程,并在平台侧渲染原生元素。我们必须放置针对每个平台的特定渲染器,但在这个例子中,我们只将为 Android 项目应用自定义渲染器,因为我们希望我们的自定义标签使用自定义字体。iOS 不需要渲染器来允许自定义字体;我们只需在info.plist文件中添加对字体文件的引用即可。在你的 iOS 项目中打开info.plist文件,并添加一个名为Fonts provided by application(对于数组元素,我们只需添加我们的字体文件路径GraCoRg_.ttf)的新条目。然后将字体文件添加到 iOS 项目的Resources文件夹中,确保字体文件的构建操作设置为BundleResource(通过右键单击文件来完成此操作):

我们还希望将此字体文件添加到 Android 项目的Assets文件夹中,并确保将构建操作设置为AndroidAsset。
你可以从 GitHub 链接获取此字体文件:github.com/flusharcade/chapter5-stocklist。
要在 Android 上实现等效功能,我们必须在Controls文件夹中为CustomLabel项目创建一个CustomRenderer。打开 Android 项目,创建一个名为Renderers的新文件夹,并添加一个名为CustomLabelRenderer的新文件,然后实现以下内容:
public class CustomLabelRenderer : LabelRenderer
{
protected override void OnElementChanged (ElementChangedEventArgs<Label> e)
{
base.OnElementChanged (e);
if (!string.IsNullOrEmpty((e.NewElement as CustomLabel)?.AndroidFontStyle))
{
try
{
var font = default(Typeface);
font = Typeface.CreateFromAsset(Forms.Context.ApplicationContext.Assets, (e.NewElement as CustomLabel)?.AndroidFontStyle + ".ttf");
if (Control != null)
{
Control.Typeface = font;
Control.TextSize = (float)e.NewElement.FontSize;
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}
}
在几乎所有渲染器中,我们都会暴露OnElementChanged函数,当在Xamarin.Forms中创建自定义控件以渲染相应的本地控件时会被调用。在某些情况下,OnElementChanged方法可能会被多次调用,因此在实例化新的本地控件时必须小心,以防止内存泄漏,这可能会对性能产生重大影响。在我们的案例中,我们并没有渲染新的控件,所以我们只需要检查当函数被调用时NewElement和Control对象不是 null。我们还必须将NewElement项转换为我们的自定义项,因为这个对象包含了AndroidFontStyle属性的定制绑定。NewElement始终是自定义项,所以我们总是可以将其转换。
我们现在也可以访问本地的 UI 框架;在这种情况下,我们使用 Android 的Typeface框架创建一个自定义的Typeface,它将使用我们的字体文件。然后这个Typeface被设置为Control元素的Typeface属性(这是实际将被显示的元素),在这种情况下,因为它是一个LabelRenderer,Control元素是一个 Android 的TextView。
注意
在其他渲染器中,我们可以将此控件元素设置为特定的本地元素,我们将在后续章节中这样做。
最后,我们必须添加以下行以导出和注册渲染器:
[assembly: Xamarin.Forms.ExportRenderer(typeof(Stocklist.XamForms.Controls.CustomLabel), typeof(Stocklist.Droid.Renderers.CustomLabel.CustomLabelRenderer))]
为自定义元素添加样式
我们还需要添加一个额外的元素来最终确定StockItemDetailsPage。我们将为CustomLabel添加一个样式。打开App.xaml文件并添加以下样式:
<Style x:Key="CustomLabelStyle" TargetType="controls:CustomLabel">
<Setter Property="TextColor" Value="Black" />
<Setter Property="FontFamily" Value="Gravur-Condensed" />
<Setter Property="AndroidFontStyle" Value="GraCoRg_" />
</Style>
我们为之前创建的AndroidFontStyle属性添加了一个Setter。别忘了我们还需要添加Controls的命名空间引用:
用户界面部分就到这里。现在让我们继续实现StockItemDetailsPage的视图模型。
创建 StockItemDetailsPageViewModel
现在我们继续到我们应用程序中的最后一个视图模型。在Stocklist.Portable项目的ViewModels文件夹中添加一个名为StockItemDetailsPageViewModel的新文件。
让我们先从实现private属性开始:
#region Private Properties
private readonly IStocklistWebServiceController _stocklistWebServiceController;
private int _id;
private string _name;
private string _category;
private decimal _price;
private bool _inProgress;
#endregion
你应该能够自己添加public属性。以下是一个开始示例:
public int Id
{
get
{
return _id;
}
set
{
if (value.Equals(_id))
{
return;
}
_id = value;
OnPropertyChanged("Id");
}
}
现在我们需要添加LoadAsync函数,它将使用StocklistWebServiceController从我们的 API 中获取特定StockItem的数据。注意InProgress属性的使用,这用于跟踪加载进度;因为我们是在后台下载,我们想要通过ActivityIndicator将此进度显示给用户界面:
#region Methods
protected override async Task LoadAsync(IDictionary<string, object> parameters)
{
InProgress = true;
if (parameters.ContainsKey("id"))
{
Id = (int)parameters["id"];
}
var contract = await _stocklistWebServiceController.GetStockItem(Id);
if (contract != null)
{
this.Name = contract.Name;
this.Category = contract.Category;
this.Price = contract.Price;
}
InProgress = false;
}
#endregion
然后我们添加我们的构造函数,它将提取已注册的 IoC 对象并相应地分配我们的private属性:
#region Constructors
public StockItemDetailsPageViewModel(INavigationService navigation, IStocklistWebServiceController stocklistWebServiceController,
Func<Action, ICommand> commandFactory) : base(navigation)
{
_stocklistWebServiceController = stocklistWebServiceController;
}
#endregion
最后,我们需要在CommonModule中注册视图模型:
builer.RegisterType<StockItemDetailsPageViewModel>().InstancePerDependency();
将额外的枚举StockItemDetailsPage添加到PageEnums.cs中:
public enum PageNames
{
MainPage,
StocklistPage,
StockItemDetailsPage
}
并在NavigationService中添加额外的 switch case:
case PageNames.StockItemDetailsPage:
return IoC.Resolve<Func<StockItemDetailsPage>>()();
设置原生平台项目
现在我们转向原生平台层,并准备 iOS、Android 和 Windows Phone 项目。我们将从 iOS 开始;让我们先添加项目所需的 NuGet 包:
-
微软 HTTP 客户端库
-
现代 HTTP 客户端
-
Autofac
-
反应式扩展(主要库)
一旦我们将这些包添加到项目中,让我们打开AppDelegate文件并添加我们在Locator项目中使用的相同的InitIoC函数:
private void InitIoC()
{
IoC.CreateContainer();
IoC.RegisterModule(new DroidModule());
IoC.RegisterModule(new SharedModule(false));
IoC.RegisterModule(new XamFormsModule());
IoC.RegisterModule(new PortableModule());
IoC.StartContainer();
}
然后在加载应用程序之前调用此方法:
public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
global::Xamarin.Forms.Forms.Init();
InitIoC();
LoadApplication(new App());
return base.FinishedLaunching(app, options);
}
在运行 iOS 应用程序之前,让我们也设置 Android 项目。我们想要从添加与 iOS 相同的库开始,然后打开MainActivity.cs,并添加与前面示例中相同的函数InitIoC。然后,最后,在我们加载应用程序之前调用InitIoC函数:
protected override void OnCreate(Bundle bundle)
{
base.OnCreate(bundle);
InitIoC();
global::Xamarin.Forms.Forms.Init(this, bundle);
LoadApplication(new App());
}
简单,对吧?看看我们有多少代码是从另一个项目中复制的?
我们在其他项目中解决的问题越多,我们就能越快地拼凑出具有相似功能的应用程序。
本地托管 Web API 项目
在我们能够从我们的移动应用程序访问 API 层之前,我们必须设置托管。对于这个例子,我们将演示本地托管的设置。
在本地托管不需要做太多工作,但它将需要一个同时运行 Windows 和 Mac OSX 的实例。你可以通过简单地运行 parallels,或者使用一台Windows和Mac电脑来实现这一点。
我们的第一步是从我们的 Windows 实例打开 Visual Studio 并点击运行按钮:

当项目启动时,它将自动打开你的默认网络浏览器并显示应用程序。
小贴士
由于我们没有任何可见的网页,我们不需要打开浏览器。如果项目正在运行,Web API 也将运行,因此我们可以通过 HTTP 请求 ping 它。
现在我们已经运行了后端,我们如何访问 API?
如果你通过两个独立的计算机运行,我们应该能够简单地通过计算机的 IP 地址访问 API。为了找出计算机的 IP 地址,打开一个新的命令提示符窗口并输入ipconfig。这将显示计算机在当前网络中分配的 IPv4 地址。

注意
为了本地设置能够工作,请确保移动设备和托管 WEB API 的计算机都在相同的 WIFI/LAN 连接上。
为了确认我们已成功设置,请将以下 URL 粘贴到 Windows 实例上的网页浏览器中,并查看是否得到结果:
"localhost:{port}/api/StockItems"
注意
当项目运行时,端口会自动分配,所以当浏览器出现带有 localhost URL 时,粘贴 URL 扩展名 api/StockItems。
现在我们想在 Mac 实例上测试这个链接,但在做之前,我们必须更改位于 "C:\Users[YourName]\Documents\IISExpress\config\applicationhost.config" 的 applicationhost.config 文件中的某些设置。
如果你使用的是 Visual Studio 2015,它将位于 /{项目文件夹}/.vs/config/applicationhost.config。
如果你还没有开启 Internet Information Services (IIS),请按照以下步骤安装 IIS 以提供静态内容:
-
点击开始按钮,点击 控制面板,点击 程序,然后点击 启用或关闭 Windows 功能。
-
在 Windows 功能 列表中,选择 Internet Information Services,然后点击 确定。
-
查看文件,直到你可以像这样添加你的应用程序条目:
注意
找到你的特定条目的最佳方式是通过搜索
端口号码。<site name="Stocklist" id="43"> <application path="/" applicationPool="Clr4IntegratedAppPool"> <virtualDirectory path="/" physicalPath="C:\Users\Michael\Documents\Stocklist\Stocklist" /> </application> <bindings> <binding protocol="http" bindingInformation="*: {PORT}:localhost" /> </bindings> </site> -
在
<bindings>部分中,我们想添加另一行:<binding protocol="http" bindingInformation="*:{PORT}:{IPv4 Address}" /> -
现在我们想允许来自其他计算机的此
PORT和IPv4 地址的入站连接。注意
如果你正在运行 Windows 7,大多数入站连接都是锁定状态,因此你需要特别允许应用程序的入站连接。
-
首先,启动一个管理命令提示符并运行以下命令,将
{IPv4}:{PORT}替换为你正在使用的IPv4 地址和PORT:> netsh http add urlacl url=http://{IPv4}:{PORT}/ user=everyone -
这只是告诉
http.sys与此 URL 通信是允许的。接下来,运行以下命令:> netsh advfirewall firewall add rule name="IISExpressWeb" dir=in protocol=tcp localport={PORT} profile=private remoteip=localsubnet action=allow -
这在 Windows 防火墙中添加了一个规则,允许本地子网上的计算机访问该端口的入站连接。
-
现在我们应该能够从我们的 Mac 实例访问正在运行的 API。这次,粘贴带有 IPv4 地址的 URL 而不是
localhost: {IPv4 address}:{port}/api/StockItems。 -
如果一切顺利,我们应该看到以下 XML 布局显示如下:
![本地托管 Web API 项目]()
-
优秀!现在让我们将这些 URL 设置添加到我们的移动应用程序中。打开
Stocklist.Portable项目的Resources文件夹中的Config.resx文件,并填写这些值:<data name="ApiAllItems" xml:space="preserve"> <value>http://{IPv4}:{PORT}/api/StockItems</value> </data> <data name="GetById" xml:space="preserve"> <value>http://{IPv4}:{PORT}/api/GetItemById</value> </data>
现在,让我们在 iOS 和 Android 上测试我们的项目,我们应该能够看到我们的 StocklistPage 从我们的 API 控制器中填充项目。
摘要
在本章中,我们使用Xamarin.Forms构建了一个用于检索网络服务的应用程序。我们设置了一个ListView并创建了一个ObservableCollection来显示 JSON 对象。我们还学习了如何在后端设置一个简单的网络服务。在下一章中,我们将使用原生库创建一个适用于 iOS 和 Android 的应用程序。我们将在客户端和服务器端使用 Signal R,并通过客户端设置中心代理连接。
第六章:建立聊天应用
在本章中,我们将回到 Xamarin 原生。我们的用户界面将远离 MVVM 设计,并遵循一个新的范式,称为模型-视图-演示者(MVP)。我们还将进一步深入后端,设置一个 SignalR Hub 和客户端来模拟聊天服务,数据将在服务器和客户端之间即时发送,因为消息变得可用。另一个关键的关注点是项目架构,花费时间将项目划分为模块,并创建一个分层结构,这将最大化不同平台之间的代码共享。
以下知识是预期的:
-
对 Xamarin 原生(iOS 和 Android)有一些了解
-
Visual Studio
-
对 OWIN 规范有一些了解
-
对 OAuth 有一些了解
在本章中,你将学习以下内容:
-
模型-视图-演示者(MVP)模式
-
架构
-
SignalR
-
从.NET 的 Open Web 接口(OWIN)开始
-
使用 OWIN OAuth 2.0 创建授权服务器
-
OAuthAuthorizationServerProvider -
授权服务器提供者
-
UseOAuthBearerAuthentication -
设置认证存储库
-
配置 Web API
-
构建
AccountController -
配置 Web API 的 OAuth 身份验证
-
构建
SignalR Hub -
设置移动项目
-
创建
SignalRClient -
构建
Web API访问层 -
应用状态
-
设置导航服务
-
构建
iOS导航服务 -
构建
Android导航服务 -
构建
iOS界面 -
处理 Hub 代理回调
-
实现
LoginPresenter -
创建演示者和视图之间的连接
-
构建
LoginActivity -
实现
ClientsListPresenter -
创建
ClientListViewController -
TaskCompletionSource框架 -
创建
ClientsListActivity -
重写
Activity的OnBackPressed() -
构建
ListAdapter -
构建
ChatPresenter -
构建
iOS的ChatView -
扩展
UIColor框架 -
Android
TableLayouts -
构建
Android的ChatActivity -
运行服务器和客户端
模型-视图-演示者(MVP)模式
在我们所有的前几章中,我们围绕模型-视图-视图-模型(MVVM)方法进行开发模式。这次我们将围绕 MVP 设计模式设置我们的项目。在 MVP 中,演示者集中了模型和视图之间的用户界面功能,这意味着所有展示逻辑都推送到演示者。

那为什么还要采用这种方法呢?
这种方法的优点是我们可以对我们的演示者应用单元测试,这意味着所有 UI 逻辑都是通过演示者进行测试的。我们还有能力保持我们的用户界面为原生,并在不同平台之间共享大量的 UI 逻辑。
架构
当涉及到跨平台应用时,我们的目标是尽可能多地共享代码。我们专注于架构,拥有一个干净的项目结构,这有助于最大化跨平台的代码共享。那么我们如何解决这个问题?问问自己:
-
有哪些不同的层级?
-
我们如何设置文件夹结构?
-
哪些部分属于哪个项目?
对于这个问题有许多不同的方法;以下是一些最常见的架构层:
-
数据层:这个层存储数据库
-
数据访问层:这个层专注于对数据层(读取、写入、更新)应用操作的对象和包装器
-
业务层(逻辑):这个层专注于不同的领域(领域驱动设计),将不同的逻辑区域分离成处理每个领域操作的对象
-
服务访问层:这个区域专注于对 Web API 的操作,我们如何处理 JSON,以及 API 控制器之间发送和接收的数据
-
应用/平台层:不共享的代码,特定于原生平台
-
通用层:一个共享的项目,代码被共享到所有原生项目中
-
用户界面层:包含所有 UI 设计的层(XAML 表单,UIViewControllers,AXML)
我们如何确定我们的项目需要哪些层?
在这个例子中相当简单;我们没有数据库,所以不需要数据层或数据访问层。我们需要的其他所有东西,让我们从零开始构建我们的项目,先从底层开始。
我们将从服务访问层开始构建项目;它将包括与 SignalR 相关的所有内容,因此我们的第一步是构建后端 SignalR hub。
SignalR
SignalR 是一个库,它为使用 WebSocket 传输(如果支持 HTML 5)的应用程序提供实时 Web 功能。它具有服务器实时将数据推送到其客户端的能力,这意味着我们不必反复请求服务器数据(如刷新/重新调用 Web API)。
为了设置 SignalR,我们首先必须在服务器端设置一个 SignalR Hub;我们的客户端(移动项目)将通过创建一个HubConnection并从其中创建一个HubProxy来访问这个Hub,从而服务器和客户端可以在任一侧调用函数。

现在让我们进入开发阶段;我们将与上一章相同的硬件设置。我们将通过 Visual Studio 设置本地托管的后端,并在 MacOSX 上的 Xamarin Studio 中构建我们的移动项目。打开 Visual Studio,创建一个newASP.NET Web 应用,并将其命名为Chat。

然后我们必须选择一个模板;选择空模板:

太好了!我们现在有了空项目,让我们先添加 NuGet 包,Microsoft.AspNet.SignalR。

将会显示一个包含设置 SignalR Hub 基本方向的说明文件。我们还想为 OWIN 添加 Web API 2.2 功能,因为我们将在项目中添加一个小型 Web API 来处理登录、注册和账户功能。让我们添加以下库:

这将安装 Web API 功能,这样我们就可以创建 API 控制器并通过 Startup 类映射路由。然后我们想添加 Web API 2.2 OWIN 库以将 OWIN 管道集成到我们的 HTTP 配置中:

我们还想添加 OWIN.Security 库来处理使用 Bearer 令牌 的账户授权。
注意
在 HTTP 请求头中使用 Bearer 令牌来授权访问 OAuth 2.0 受保护的资源。

最后,我们必须添加另一个名为 Microsoft.AspNet.Identity.Framework 的包。这个库将用于使用 UserManager 框架来处理用户账户(用户名和密码)的存储。

现在我们已经添加了所有包,让我们从头开始构建网络应用程序。
从 Open Web Interface for .NET (OWIN) 开始
OWIN 是 .NET 服务器和 Web 应用程序之间的标准接口。它提供了一个中间件,用于解耦 Web 服务器和 Web 应用程序。OWIN 的最大优点是我们能够将 Web 应用程序托管在任何地方,并保持服务器和应用程序完全分离。
注意
关于 OWIN 的更多信息,最好的起点是 Katana 项目。Katana 是一系列支持 OWIN 的项目,它们使用各种 Microsoft 组件。
那么 OWIN 与我们的项目有什么关系呢?
如果你注意上面的代码,我们会看到所有对 OWIN 命名空间的引用,并且我们在程序集中将 OwinStartup 对象注册到我们的 Startup 类。我们必须在 OwinStartup 属性中注册至少一个 Startup 类。Startup 类有一个名为 Configuration 的函数。所有 Startup 类都必须包含此函数,并且它必须接受 IAppBuilder。还可以指定其他服务,如 IHostingEnvironment 和 ILoggerFactory,在这种情况下,如果它们可用,服务器将注入这些服务。Configuration 指定应用程序如何响应单个 HTTP 请求。最后,在我们的 Configuration 方法中,我们将调用 MapSignalR(IAppBuilder 对象的扩展)。这将定义客户端用于连接到您的 Hub/s 的路由。
注意
默认情况下,路由设置为 URL /signalr 的应用程序构建器管道:如果需要,我们也可以自定义此 URL。
我们下一步是引入一些安全性。
使用 OWIN OAuth 2.0 创建授权服务器
OAuth 2.0 框架允许服务器向客户端提供对 HTTP 服务的有限访问。受保护的资源只能通过在特定时间段后过期的访问令牌访问。客户端将向域端点 URL 发送 HTTP 请求(通常是 /token),服务器将发送包含令牌详细信息(如过期时间、访问令牌、时间/日期)的响应,并且访问令牌将在一段时间内与其他 HTTP 请求头一起使用,以授权访问受保护资源。
注意
访问令牌是表示特定范围、生存期和其他访问属性的字符串。
那么我们该从哪里开始设置服务器授权?
我们的第一步是构建基于用户名和密码凭证授予客户端访问的逻辑。
OAuthAuthorizationServerProvider
OAuthAuthorizationServerProvider 确定了我们如何使用 OAuthGrantResourceOwnerCredentialsContext 验证用户凭证。其任务是简单地处理用户的身份验证。此项目提供了我们处理资源授予的上下文。
让我们添加一个名为 Providers 的新文件夹,并在该文件夹中添加一个名为 AuthorizationServerProvider.cs 的新文件。实现以下内容:
public class AuthorizationServerProvider : OAuthAuthorizationServerProvider
{
public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
context.Validated();
}
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
context.OwinContext.Response.Headers.Add("Access-Control-Allow-Origin", new[] { "*" });
string userName = null;
using (AuthenticationRepository authenticationRepository = new AuthenticationRepository())
{
IdentityUser user = await authenticationRepository.FindUser(context.UserName, context.Password);
if (user == null)
{
context.SetError("invalid_grant", "Incorrect user name or password");
return;
}
userName = user.UserName;
}
var identity = new ClaimsIdentity(context.Options.AuthenticationType);
identity.AddClaim(new Claim("Role", "User"));
identity.AddClaim(new Claim("UserName", userName));
context.Validated(identity);
}
}
我们对 OAuthAuthorizationServerProvider 的实现将覆盖 ValidateClientAuthentication 函数,该函数简单地返回 usercontext 是否已验证。然后我们覆盖 GrantResourceOwnerCredentials() 函数,当带有 grant_type 为 password 的请求到达令牌端点(/token)时,该函数会被调用(此键与用户名和密码一起设置在请求头中)。该函数将简单地初始化一个新的 AuthenticationRepository 以访问 UserManager 框架并检查用户是否存在;如果不存在,我们返回,并且上下文仍然无效。如果用户存在,我们创建一个新的 ClaimsIdentity 对象,包含两个声明,一个用于源所有者(发送 HTTP 请求的用户)的 角色 和 用户名 原则。最后,我们将 ClaimsIdentity 对象放入 context.Validated() 函数中,以颁发访问令牌。这个 ClaimsIdentity 对象现在是包含与访问令牌关联的资源所有者(用户)声明的票据。
注意
ClaimsIdentity 是一个对象,它是一个 Claim 对象的集合,用于表示实体的身份。每个 Claim 对象只是一个描述身份角色、权限或其他实体质量的陈述。
使用 OAuthBearerAuthentication
我们接下来的步骤是添加处理携带令牌(这些是由授权服务器提供者授予的访问令牌)的逻辑。UseOAuthBearerAuthentication 的任务是确保只有经过身份验证的用户才能访问您的受保护服务器资源(在我们的例子中是 ChatHub)。添加一个名为 OAuthBearerTokenAuthenticationProvider.cs 的新文件并实现以下内容:
public class OAuthBearerTokenAuthenticationProvider : OAuthBearerAuthenticationProvider
{
public override Task RequestToken(OAuthRequestTokenContext context)
{
string cookieToken = null;
string queryStringToken = null;
string headerToken = null;
try
{
cookieToken = context.OwinContext.Request.Cookies["BearerToken"];
}
catch (NullReferenceException)
{
System.Diagnostics.Debug.WriteLine("The cookie does not contain the bearer token");
}
try
{
queryStringToken = context.OwinContext.Request.Query["BearerToken"].ToString();
}
catch (NullReferenceException)
{
System.Diagnostics.Debug.WriteLine("The query string does not contain the bearer token");
}
try
{
headerToken = context.OwinContext.Request.Headers["BearerToken"];
}
catch (NullReferenceException)
{
System.Diagnostics.Debug.WriteLine("The connection header does not contain the bearer token");
}
if (!String.IsNullOrEmpty(cookieToken))
context.Token = cookieToken;
else if (!String.IsNullOrEmpty(queryStringToken))
context.Token = queryStringToken;
else if (!String.IsNullOrEmpty(headerToken))
context.Token = headerToken;
return Task.FromResult<object>(null);
}
}
让我们更仔细地看看这个项目。我们正在重写 RequestToken() 函数,以便从每个击中服务器的 HTTP 请求中访问 OAuthRequestTokenContext。在 OwinContext 对象内部,我们可以访问刚刚击中服务器的 HTTP 请求,通过头部字典检查我们的 BearerToken,然后提取这个访问令牌并将其分配给 OAuthRequestTokenContext.Token 属性。
设置 AuthenticationRepository
现在我们转向 AuthenticationRepository。这是将处理访问和存储的对象,使用由 Identity.EntityFramework 库提供的 UserManager 框架。添加一个名为 Repositories 的新文件夹,然后添加一个名为 AuthenticationRepository.cs 的新文件,并实现以下内容:
注意
UserManager 类是任何 ASP.Net 应用程序提供身份管理的门面
public class AuthenticationRepository : IDisposable
{
private AuthenticationContext authenticationContext;
private UserManager<IdentityUser> userManager;
public AuthenticationRepository()
{
authenticationContext = new AuthenticationContext();
userManager = new UserManager<IdentityUser>(new UserStore<IdentityUser>(authenticationContext));
}
public async Task<IdentityResult> RegisterUser(UserModel userModel)
{
IdentityUser newUser = new IdentityUser()
{
UserName = userModel.Username
};
var foundUser = await userManager.FindByNameAsync(newUser.UserName);
if (foundUser != null)
{
await userManager.RemovePasswordAsync(foundUser.Id);
return await userManager.AddPasswordAsync(foundUser.Id, userModel.Password);
}
else
{
return await userManager.CreateAsync(newUser, userModel.Password);
}
}
public async Task<IdentityUser> FindUser(string userName, string password)
{
return await userManager.FindAsync(userName, password);
}
public void Dispose()
{
authenticationContext.Dispose();
userManager.Dispose();
}
}
我们在这里的主要关注点涉及两个函数,一个用于注册用户(如果他们不存在),另一个用于查找用户。授权服务器提供者使用 FindUser 来确定用户是否存在以确认身份验证。
我们还需要添加另一个名为 AuthenticationContext.cs 的文件,并实现以下内容:
public class AuthenticationContext : IdentityDbContext<IdentityUser>
{
public AuthenticationContext()
: base("AuthenticationContext")
{
}
}
这是一个非常简单的类,它继承自 IdentityDBContext 类型的 IdentityUser。该对象是访问层,用于通过 EntityFramework 获取数据对象(IdentityUser 对象)。以下图表显示了您的 ASP.Net 应用程序和 EntityFramework 之间的逻辑层:

太棒了!希望那些主题没有太令人困惑。现在让我们开始构建 Web API。
配置 Web API
我们下一步是配置 Web API。让我们添加一个名为 App_Start 的新文件夹。在这个文件夹内添加一个名为 WebApiConfig.cs 的新文件,并实现以下内容:
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{action}/{id}",
defaults: new { id = RouteParameter.Optional }
);
}
}
更仔细地看看 routeTemplate;注意到 {action} 的添加吗?这意味着我们必须在每个 AccountController 中的函数上包含 ActionName 属性。ActionName 属性代表 URL 扩展名,例如:
ActionName("Register") = http://{IP Address}:{Port}/Register
现在,让我们添加另一个名为 Startup.cs 的文件,并实现以下内容:
public class Startup
{
public void Configuration(IAppBuilder app)
{
HttpConfiguration config = new HttpConfiguration();
WebApiConfig.Register(config);
app.UseWebApi(config);
}
}
现在让我们继续构建 AccountController 来处理用户登录和注册的传入 HTTP 请求。
构建 AccountController
现在我们已经配置了 Web API,让我们构建第一个 API 控制器。添加一个名为 Models 的新文件夹。在这个文件夹内,添加一个名为 UserModel.cs 的新文件,并实现以下内容:
public class UserModel {
[Required]
public string Username { get; set; }
[Required]
public string Password { get; set; }
}
该对象将包含客户端通过 HTTP 请求传递的 username 和 password 字段。Register属性用于确保此属性包含在 HTTP 请求中。然后我们可以将此属性映射到 API 控制器的ModelState.IsValid检查,如果任何具有此属性的属性缺失,则IsValid属性将为false。接下来,让我们添加另一个名为Controllers的文件夹。在这个文件夹中添加一个名为AccountController.cs的新文件,并实现以下内容:
public class AccountController : ApiController
{
private AuthenticationRepository authenticationRepository;
public AccountController()
{
authenticationRepository = new AuthenticationRepository();
}
[HttpPost]
[AllowAnonymous]
[ActionName("Register")]
public async Task<IHttpActionResult> Register(UserModel userModel)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var result = await authenticationRepository.RegisterUser(userModel);
return Ok();
}
}
我们的第一步是Register函数,它负责通过AccountRepository将新用户存储到UserManager中。
注意到ModalState.IsValid上的 if 语句吗?
如果 HTTP 请求中缺少Username或Password属性,它将返回false。
现在让我们添加Login函数:
[HttpPost]
[AllowAnonymous]
[ActionName("Login")]
public async Task<bool> Login(UserModel userModel)
{
if (!ModelState.IsValid)
{
return false;
}
var result = await
authenticationRepository.FindUser(userModel.Username,
userModel.Password);
return (result != null);
}
这与Register完全相同,但我们使用FindUser函数来检查用户是否存在于UserManager中。最后,为了避免任何内存泄漏,我们需要确保当 API 控制器被销毁时,AuthenticationRepostiory也被销毁。让我们像这样重写Dispose函数:
protected override void Dispose(bool disposing)
{
if (disposing)
authenticationRepository.Dispose();
base.Dispose(disposing);
}
太好了!对于AccountController来说,这就是全部内容;现在我们必须将 OAuth 身份验证和 Web API 集成在一起。
使用我们的 Web API 配置 OAuth 身份验证
为了将我们的 OAuth 模块与 Web API 集成,我们必须在Startup.cs中添加一些额外的配置。添加一个名为ConfigureOAuth的新函数,如下所示:
public class Startup
{
...
public void ConfigureOAuth(IAppBuilder app)
{
OAuthAuthorizationServerOptions OAuthServerOptions = new OAuthAuthorizationServerOptions()
{
AllowInsecureHttp = true,
TokenEndpointPath = new PathString("/token"),
AccessTokenExpireTimeSpan = TimeSpan.FromDays(1),
Provider = new AuthorizationServerProvider()
};
app.UseOAuthAuthorizationServer(OAuthServerOptions);
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions()
{
Provider = new OAuthBearerTokenAuthenticationProvider()
});
}
...
}
仔细观察,我们首先实例化一个新的OAuthAuthorizationServerOptions对象,我们设置端点 URL、访问令牌的过期期限,并将提供者设置为在先前的示例中创建的AuthorizationServerProvider类。然后我们使用UseOAuthAuthorizationServer函数将此对象添加到IAppBuilder对象中。最后,我们创建一个新的OAuthBearerAuthenticationOptions对象,其中提供者设置为在先前的示例中创建的OAuthBearerTokenAuthenticationProvider对象。
到此为止;我们现在已经将 OAuth 身份验证集成到我们的 Web API 中。现在让我们实现服务器应用程序的最后一部分。
构建 SignalR Hub
ChatHub将负责使用ConnectionId在客户端之间路由消息。让我们添加一个名为ChatHub的新文件,并从重写OnConnected和OnDisconnected函数开始:
[Authorize]
public class ChatHub : Hub
{
public static readonly ConcurrentDictionary<string, SigRUser> Users
= new ConcurrentDictionary<string, SigRUser>(StringComparer.InvariantCultureIgnoreCase);
public override Task OnConnected()
{
var userName = (Context.User.Identity as ClaimsIdentity).Claims.FirstOrDefault(claim => claim.Type == "UserName").Value;
string connectionId = Context.ConnectionId;
var user = Users.GetOrAdd(userName, _ => new SigRUser
{
Name = userName,
ConnectionIds = new HashSet<string>()
});
lock (user.ConnectionIds)
{
user.ConnectionIds.Add(connectionId);
NotifyOtherConnectedUsers(userName);
}
return base.OnConnected();
}
public override Task OnDisconnected(bool stopCalled)
{
var userName = (Context.User.Identity as ClaimsIdentity).Claims.FirstOrDefault(claim => claim.Type == "UserName").Value;
string connectionId = Context.ConnectionId;
SigRUser user;
Users.TryGetValue(userName, out user);
if (user != null)
{
lock (user.ConnectionIds)
{
SigRUser removedUser;
Users.TryRemove(userName, out removedUser);
NotifyOtherConnectedUsers(userName);
}
}
return base.OnDisconnected(stopCalled);
}
}
HashSetUsers是静态的,因为我们将在AccountController中使用它。
注意到这个 Authorize 属性吗?
这就是我们创建受保护服务器资源的方式;只有获得访问令牌的客户端才能连接到ChatHub。
现在,让我们将注意力转向OnConnected函数。当客户端连接到ChatHub时,用户名是从HubCallerContext属性中检索的,该属性实际上是一个ClaimsIdentity对象。当我们通过AccountController登录时,在AuthorizationServerProvider内部,当调用GrantResourceOwnerCredentials函数时,我们将身份对象存储在上下文中。我们还在身份中存储了一个类型为username的Claim对象,我们现在可以从HubCallerContext中的用户身份中检索它。这就是我们将 OAuth 与 SignalR 集成的样子。
现在我们已经获得了用户名,我们将尝试从ConcurrentDictionary中检索一个SigRUser对象;如果用户名不存在,我们创建一个新的SignRUser并将其添加到HashSet中。然后我们锁定ConnectionIdsConcurrentDictionary,使其线程安全,因为多个线程(不同的用户连接)可以对此属性进行更改。在锁定语句中,我们添加新的ConnectionId并使用NotifyOtherConnectedUsers函数通知所有连接到ChatHub的其他用户。现在让我们将这个函数添加到ChatHub中:
public void NotifyOtherConnectedUsers(string userName)
{
var connectionIds = Users.Where(x => !x.Key.Contains(userName))
.SelectMany(x => x.Value.ConnectionIds)
.Distinct();
foreach (var cid in connectionIds)
{
Clients.Client(cid).displayMessage("clients", JsonConvert.SerializeObject(Users.Select(x => x.Key)));
}
}
这个函数将调用displayMessage,向所有其他连接的客户端发送ConcurrentDictionary Users的序列化 JSON 对象(我们稍后会看到原因)。
现在,让我们将注意力转向OnDisconnected函数。这个函数将简单地检查是否存在一个用户名为从HubCallerContext对象中检索到的SigRUser。如果此用户存在,我们尝试将其从ConcurrentDictionary中删除,并再次调用NotifyOtherConnectedUsers,将更新后的客户端字典发送给剩余的连接客户端。
小贴士
我们每次用户连接或断开与中心连接时都会调用这个函数,因此在我们的移动应用程序中,我们可以实时更新连接客户端的列表,而无需刷新页面。
现在我们能够处理连接客户端的更新列表,我们的最后一步是添加一个在两个客户端之间发送消息的函数。Send函数将通过客户端的中心代理调用,带有两个参数(消息和用户名):
public void Send(string message, string to)
{
SigRUser receiver;
if (Users.TryGetValue(to, out receiver))
{
var userName = (Context.User.Identity as ClaimsIdentity).Claims.FirstOrDefault(claim => claim.Type == "UserName").Value;
SigRUser sender;
Users.TryGetValue(userName, out sender);
lock (receiver.ConnectionIds)
{
foreach (var cid in receiver.ConnectionIds)
{
Clients.Client(cid).displayMessage("chat", message);
}
}
}
}
这就是我们的后端全部内容。我们现在已经为服务访问层添加了第一个功能。
小贴士
服务器服务访问层将位于移动项目不同的服务访问层中。与服务器和客户端代码一样,系统的每一侧都将有自己的架构和层。
现在,让我们转向客户端并开始构建我们的移动应用程序。
设置移动项目
现在,让我们回到移动端;在我们的移动项目中,我们将原生地在 Android 和 iOS 上设置 SignalR 客户端。我们还将创建一个表示层来在两个原生平台之间共享 UI 逻辑。打开 Xamarin Studio 并创建一个名为Chat.Common的新共享项目;在这个项目中添加两个名为Model和Presenter的空文件夹。
然后,我们想要创建一个单个视图 iOS 应用程序,一个通用 Android 应用程序,以及一个名为Chat.ServiceAccess的共享项目。我们的项目结构将如下所示:

创建 SignalRClient
我们将开始实现一个新的类,称为SignalRClient。这将位于服务访问层,名为Chat.ServiceAccess的共享项目中。创建一个名为SignalRClient.cs的新文件,并实现以下内容:
public class SignalRClient
{
private readonly HubConnection _connection;
private readonly IHubProxy _proxy;
public event EventHandler<Tuple<string, string>> OnDataReceived;
public SignalRClient()
{
_connection = new HubConnection("http://{IP Address}:{Port}/");
_proxy = _connection.CreateHubProxy("ChatHub");
}
}
现在让我们更仔细地看看。我们有两个readonly属性,在对象创建时只初始化一次,一个是设置到服务器 URL 的 hub 连接,另一个是从连接到服务器的HubProxy创建的。
现在让我们为ChatHub添加两个用于连接和断开连接的功能:
public async Task<bool> Connect(string accessToken)
{
try
{
_connection.Headers.Add("Authorization",
string.Format("Bearer {0}", accessToken));
await _connection.Start();
_proxy.On<string, string>("displayMessage", (id, data) =>
{
if (OnDataReceived != null)
{
OnDataReceived(this, new Tuple<string,
string>(id, data));
}
});
return true;
}
catch (Exception e)
{
Console.WriteLine(e);
}
return false;
}
public void Disconnect()
{
_connection.Stop();
_connection.Dispose();
}
Connect函数需要一个访问令牌,我们将它添加到HubConnection对象的Headers字典中。
注意
访问令牌用作 Bearer 令牌,以授权对ChatHub的访问。
从代理中调用的On函数接受两个参数,即我们正在监听的服务器上函数的名称,以及每次在 Hub 的已连接客户端上调用此函数时将执行的操作。在这个例子中,我们的代理将在从服务器接收两个字符串时触发此操作。第一个字符串是传递到第二个字符串中的数据的 ID(这可能是一个包含已连接客户端的 JSON 列表或一个简单的聊天消息)。然后,这些数据将通过一个Tuple<string, string>对象传递给EventHandler。
注意
我们可以为多个函数调用On,并为在Hub上被调用的每个不同函数触发不同的操作。
Disconnect函数简单地关闭连接并释放HubConnection对象。最后,我们添加另一个函数,通过服务器上的ChatHub对象调用Send函数:
public async Task SendMessageToClient(string user, string message)
{
await _proxy.Invoke("Send", new object[]
{
message,
user
});
}
当我们调用服务器函数时,我们使用一个对象数组,以匹配服务器函数所需的参数。
由于SignalRClient将位于一个共享项目中,相同的代码将在每个不同的平台上使用,但引用的库将从每个平台项目提供。现在让我们让 iOS 和 Android 项目都引用这个共享项目。我们还想为所有平台项目(iOS 和 Android)添加Microsoft.AspNet.SignalR.Client NuGet 包。

如果你尝试使用 Xamarin.iOS 1.0 添加 SignalR 版本 2.2.0 的 NuGet 包,该包将无法添加。如果出现这种情况,请访问以下链接,并将lib文件夹中的正确.dll文件添加到每个平台项目的引用中:components.xamarin.com/auth?redirect_to=%2fdownload%2fsignalr。

提示
为了正确添加引用,右键单击每个项目的引用文件夹,点击.Net 程序集选项卡,然后点击浏览按钮添加.dll文件(Microsoft.AspNet.SignalR.Client、System.Net.Http.Extensions和System.Net.Http.Primitives)。
对于每个平台项目,我们还需要从 NuGet 添加Json.Net包,然后右键单击引用,点击所有选项卡,并选择System.Net和System.Net.Http。

既然我们已经配置了SignalR,让我们继续构建WebApiAccess层。
构建 WebApiAccess 层
我们的WebApiAccess对象将被映射到服务器上的AccountController。让我们添加一个名为WebApiAccess.cs的新文件,并实现LoginAsync函数:
public class WebApiAccess
{
private string _baseAddress = "http://{IP Address}:{Port}/";
public async Task<bool> LoginAsync(string name, string password,
CancellationToken? cancellationToken = null)
{
var httpMessage = new HttpRequestMessage(HttpMethod.Post,
new Uri(_baseAddress + "api/Account/Login"))
{
Content = new StringContent(string.Format
("Username={0}&Password={1}", name, password), Encoding.UTF8,
"application/x-www-form-urlencoded"),
};
var client = new HttpClient();
var response = await client.SendAsync(httpMessage,
cancellationToken ?? new CancellationToken(false));
switch (response.StatusCode)
{
case HttpStatusCode.NotFound:
throw new Exception(string.Empty);
}
var responseContent = await response.Content.ReadAsStringAsync();
var loginSuccess = false;
bool.TryParse(responseContent, out loginSuccess);
return loginSuccess;
}
}
_baseAddress属性将与SignalRHubConnection地址相同;这是我们服务器链接。在我们的LoginAsync函数中,我们首先创建一个新的HttpRequestMessage,将其设置为HttpMethod.Post。我们还设置了内容为一个新的StringContent对象,该对象包含用户名和密码。这条消息用于一个新的HttpClient,发送到服务器,并将接收到的响应作为字符串读取并解析为一个新的bool对象,以确定登录是否成功。
让我们继续实现访问层的其余部分:
public async Task<bool> RegisterAsync(string name, string password, CancellationToken? cancellationToken = null)
{
var httpMessage = new HttpRequestMessage(HttpMethod.Post,
new Uri(_baseAddress + "api/Account/Register"))
{
Content = new StringContent(string.Format
("Username={0}&Password={1}", name, password), Encoding.UTF8,
"application/x-www-form-urlencoded"),
};
var client = new HttpClient();
var response = await client.SendAsync(httpMessage,
cancellationToken ?? new CancellationToken(false));
return response.StatusCode == HttpStatusCode.OK;
}
Register函数与之前非常相似,但我们只检查响应状态码是否为200(OK)响应;如果是这样,那么我们已成功注册。
public async Task<TokenContract> GetTokenAsync(string name, string password, CancellationToken? cancellationToken = null)
{
var httpMessage = new HttpRequestMessage(HttpMethod.Post,
new Uri(_baseAddress + "token"))
{
Content = new StringContent(string.Format
("Username={0}&Password={1}&grant_type=password", name,
password), Encoding.UTF8, "application/x-www-form-urlencoded"),
};
var client = new HttpClient();
var response = await client.SendAsync(httpMessage,
cancellationToken ?? new CancellationToken(false));
switch (response.StatusCode)
{
case HttpStatusCode.NotFound:
throw new Exception(string.Empty);
}
var tokenJson = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<TokenContract>(tokenJson);
}
GetTokenAsync函数负责从 OAuth 端点(/token)检索访问令牌。JSON 响应将是TokenContract类型;让我们继续将此对象添加到Chat.ServiceAccess项目中。在Web文件夹内创建一个名为Contracts的新文件夹,添加一个名为TokenContract.cs的新文件,并实现以下内容:
public class TokenContract
{
[JsonProperty("access_token")]
public string AccessToken { get; set; }
[JsonProperty("token_type")]
public string TokenType { get; set; }
[JsonProperty("expires_in")]
public int ExpiresIn { get; set; }
[JsonProperty("userName")]
public string Username { get; set; }
[JsonProperty(".issued")]
public string IssuedAt { get; set; }
[JsonProperty(".expires")]
public string ExpiresAt { get; set; }
}
注意到JsonProperty属性吗?
我们可以将 JSON 对象中的属性映射到类的其他命名变量中。
现在是最后的 Web API 函数GetAllConnectedUsersAsync。当用户首次登录时,将调用此函数。我们需要同时进行 API 调用和与SignalRClient的实时更新,以跟踪当前连接的客户端,因为当新用户登录时,服务器将在所有其他客户端上调用displayMessage。即使我们调用displayMessage在Clients.All(这是对任何SignalR Hub上所有连接客户端的引用),新连接的客户端也不会出现在客户端列表中,因为连接存在轻微的延迟。
小贴士
这种轻微的延迟是我们无法控制的;只有有时新连接的客户端才会通过HubProxy事件接收到更新后的列表。因此,为了使事情更加可靠,我们在 API 访问层添加了这个更新。
让我们为 GetAllConnectedUsersAsync 添加最终的 Web API 函数。此函数将反序列化一个表示连接客户端列表的字符串的 IEnumerable,来自 ChatHub:
public async Task<IEnumerable<string>>
GetAllConnectedUsersAsync(CancellationToken? cancellationToken = null)
{
var httpMessage = new HttpRequestMessage(HttpMethod.Get,
new Uri(_baseAddress + "api/Account/GetAllConnectedUsers"));
var client = new HttpClient();
var response = await client.SendAsync(httpMessage,
cancellationToken ?? new CancellationToken(false));
switch (response.StatusCode)
{
case HttpStatusCode.NotFound:
throw new Exception(string.Empty);
}
var responseContent = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<IEnumerable<string>>
(responseContent);
}
太好了!我们现在已经有了我们的 Web API 访问层。我们的下一步是开始构建每个演示者所需的每个应用程序状态和导航服务。
应用程序状态
在 MVP 中,每个演示者都必须包含当前的应用程序状态。当我们跨不同屏幕时,应用程序数据的持久状态将在整个应用程序的生命周期中保持活跃(这包括搜索结果、下载的 JSON 对象等)。
提示
在许多 MVP 应用程序中,应用程序状态将包括一个用于在不同会话之间保存和加载此持久数据的保存和加载服务。作为一个额外的学习活动,尝试实现一个名为 IApplicationStateService 的新服务。这个服务将负责将 ApplicationState 对象本地保存到您的设备上。
太棒了!现在让我们添加另一个名为 ApplicationState.cs 的文件,并实现以下内容:
public class ApplicationState
{
#region Public Properties
public string AccessToken { get; set; }
public string Username { get; set; }
#endregion
}
没有什么特别的,对吧?
我们在整个应用程序的生命周期中只想有一个此对象的实例,因此我们将基于需要在每个屏幕之间保持活跃的持久数据来构建。
设置导航服务
在 MVP 中实现导航服务与我们的 Xamarin.Forms 导航服务非常不同。这次我们的导航服务将不会在 IoC 容器中使用;相反,我们将在应用程序启动时在 AppDelegate 和 MainActivity 类中实例化这些对象之一。由于我们在原生环境中工作,我们还将为每个平台实现一个单独的导航服务,这些服务将共享相同的接口。
让我们从创建共享接口开始。在 Chat.Common 文件夹中的 Presenter | Services 下添加一个新文件,命名为 INavigationService.cs,并实现以下内容:
public interface INavigationService { void PushPresenter(BasePresenter presenter); }
构建 iOS 导航服务
让我们从构建 iOS 导航服务开始。在 Chat.iOS 项目中添加一个名为 Services 的新文件夹,创建一个名为 NavigationService.cs 的新文件,并实现以下内容:
public class NavigationService : INavigationService
{
#region Private Properties
private UINavigationController _navigationController;
#endregion
#region Constructors
public NavigationService(UINavigationController navigationController)
{
_navigationController = navigationController;
}
#endregion
#region INavigationService implementation
public void PushPresenter(BasePresenter presenter)
{
if (presenter is LoginPresenter)
{
var viewController = new LoginViewController
(presenter as LoginPresenter);
_navigationController.PushViewController(viewController, true);
}
}
public void PopPresenter(bool animated)
{
_navigationController.PopViewController(animated);
}
#endregion
}
当我们实例化此对象时,我们总是想传递分配给我们在 AppDelegate 中创建的 UIWindow 对象的 RootViewController 的 UINavigationController。我们还必须实现 Push 函数,该函数接受一个 BasePresenter 对象(任何演示者),我们执行类型检查以确定传递的是哪个演示者,并将相关的 UIViewController 推送到导航堆栈中。我们必须始终将演示者传递给新的 UIViewController,这样我们就可以将新的视图注册到当前演示者。
构建 Android 导航服务
在我们转向 Android 导航服务之前,我们必须添加一个额外的类来在当前活动、当前演示者和当前上下文中保持持久状态。添加一个名为Application.cs的新文件,并实现以下内容:
[Application]
public class ChatApplication : Application
{
#region Public Properties
public object Presenter
{
get;
set;
}
public Activity CurrentActivity
{
get;
set;
}
#endregion
#region Constructors
public ChatApplication()
: base()
{
}
public ChatApplication(IntPtr javaReference,
JniHandleOwnership transfer)
: base(javaReference, transfer)
{
}
#endregion
#region Public Methods
public static ChatApplication GetApplication(Context context)
{
return (ChatApplication)context.ApplicationContext;
}
#endregion
}
这个类将扩展 Android 应用程序,因此当我们在其他部分的应用程序中引用 Android 应用程序类时,我们将引用额外的持久对象。
现在让我们实现 Android 导航服务。在 Android 项目中添加一个名为Services的新文件夹,添加一个名为NavigationService.cs的新文件,并实现以下内容:
public class NavigationService : INavigationService
{
private ChatApplication _application;
public NavigationService(ChatApplication application)
{
_application = application;
}
public void PushPresenter(BasePresenter presenter)
{
var oldPresenter = _application.Presenter as BasePresenter;
if (presenter != oldPresenter)
{
_application.Presenter = presenter;
Intent intent = null;
if (presenter is LoginPresenter)
{
intent = new Intent(_application.CurrentActivity,
typeof(LoginActivity));
}
if (intent != null)
{
_application.CurrentActivity.StartActivity(intent);
}
}
}
public void PopPresenter(bool animated)
{
_application.CurrentActivity.Finish();
}
}
在构造函数中,我们传入Application对象,并将其作为私有变量存储在导航服务中。Push函数每次我们向堆栈中推送一个新的Activity时都需要Application,因为我们需要当前活动引用来从Application对象中启动新的意图。
现在我们已经有了导航服务和应用程序状态,让我们开始构建 iOS 的用户界面。
构建 iOS 界面
由于我们并不真正知道每个屏幕的用户界面将如何看起来,我们无法在我们的演示者中定义逻辑。所以让我们大致谈谈用户界面将如何看起来。
小贴士
在我们转向项目的 UI 层之前,我们通常应该有屏幕原型。
我们的应用程序中有三个屏幕,其中一个将是一个列表,显示服务器上ChatHub上所有连接的客户端。用户可以从列表中选择这个用户;当用户从列表中选择另一个客户端时,这个客户端应该收到一条请求权限的消息,以开始聊天对话。当用户接受时,这将转到另一个屏幕,显示典型的聊天对话,与任何其他短信应用程序(两侧的语音气泡)类似。以下图表是三个屏幕和工作流程的快速原型。我们看到的是第一个屏幕显示登录屏幕,然后是另一个显示连接客户端的列表,最后是显示两个连接客户端之间对话的屏幕。

太棒了!现在我们已经有了关于屏幕外观的初步想法,让我们来谈谈第一个可以分享的屏幕背后的逻辑。我们有一个屏幕,包含两个输入框,用于输入用户名和密码。这个屏幕将能够在我们 Web API 上执行登录和注册操作,因此我们需要为每个操作提供一个按钮。如果我们登录成功,这将把列表页面推送到导航堆栈中。
让我们考虑一个跨平台的方法;我们在这里可以共享什么?
-
Web API 层
-
事件处理器用于处理登录和注册的点击事件
-
导航服务用于处理导航堆栈上的推送/弹出
我们对第一个屏幕背后的逻辑有一个大致的了解;让我们构建我们的第一个演示者。创建两个新的文件,分别命名为BasePresenter.cs和IView.cs。我们将从IView类开始:
public interface IView
{
void SetMessage(string message);
bool IsInProgress
{
get;
set;
}
}
我们希望所有屏幕都有一个IsInProgress变量,如果任何屏幕正在加载或处理,我们可以向用户显示加载活动。SetMessage函数用于通过警告对话框向用户显示任何错误。
现在对于BasePresenter,这是一个抽象类,将被用于所有演示者。所有演示者都需要ApplicationState、INavigationService和SignalRClient。在整个应用程序中,每个屏幕都需要从SignalRClient接收事件才能正常工作,因此我们可以将其引入BasePresenter对象。我们已经创建了两个EventHandlers;这些是基于通过SignalRClient上的hub 代理接收到的数据触发的。如果我们收到一个客户端列表,我们将触发ConnectedClientsUpdated事件。如果我们收到一个string,我们将触发ChatReceived事件,这样我们实际上可以通过BasePresenter类控制所有SignalR数据,并将特定数据类型通道到特定事件,以便我们的视图进行注册。我们还有一个WebApiAccess对象用于访问 Web API,以及一个字符串用于在登录成功时保存访问令牌:
public abstract class BasePresenter
{
#region Private Properties
private IDictionary<string, Action<string>> _signalREvents;
#endregion
#region Protected Properties
protected INavigationService _navigationService;
protected ApplicationState _state;
protected SignalRClient _signalRClient;
protected WebApiAccess _webApiAccess;
protected string _accessToken;
#endregion
#region Events
public event EventHandler<ConnectedClientsUpdatedEventArgs>
ConnectedClientsUpdated;
public event EventHandler<ChatEventArgs> ChatReceived;
#endregion
}
处理 Hub 代理回调
让我们把注意力转向SignalRClient;我们创建了一个EventHandler,每当从Hub接收到数据时都会触发。BasePresenter将负责处理从该EventHandler接收到的数据:
#region Constructors
public BasePresenter()
{
_webApiAccess = new WebApiAccess();
_signalREvents = new Dictionary<string, Action<string>>()
{
{"clients", (data) =>
{
var list = JsonConvert.DeserializeObject<IEnumerable<string>>(data);
if (ConnectedClientsUpdated != null)
{
ConnectedClientsUpdated(this, new ConnectedClientsUpdatedEventArgs(list.Select(x => new Client
{
Username = x,
})));
}
}
},
{"chat", (data) =>
{
if (ChatReceived != null)
{
ChatReceived(this, new ChatEventArgs(data));
}
}
},
};
}
#endregion
#region Protected Methods
protected void HandleSignalRDataReceived(object sender, Tuple<string, string> e)
{
_signalREventse.Item1;
}
#endregion
提示
私有字典_signalREvents用于代替 switch 语句。
每次从 SignalRClient 的OnDataReceived事件接收到的Tuple,第一个字符串将是匹配字典中索引Action<string>的键。Tuple中的另一个字符串是数据字符串(要么是HashSet<string>的序列化 JSON,要么是表示聊天消息的字符串),它作为我们的Action<string>的输入参数传递,然后,从这个输入参数中,我们将创建用于指定事件的正确参数。
注意
我们可以将view对象进一步抽象到BasePresenter中,因为每个演示者都需要一个view,但由于每个视图逻辑都是独立的,将这种逻辑集中在一个区域是非常困难的。如果多个视图具有类似的行为,这种需求就会出现。然后我们可以将这些区域抽象到BasePresenter中。
但等等!你可能已经注意到,我们正在将两种类型的参数传递给每个EventHandler。在Chat.Common项目的Events文件夹中添加一个新的文件,命名为ConnectedClientsUpdatedEventArgs.cs,并实现以下内容:
public class ConnectedClientsUpdatedEventArgs : EventArgs
{
public IList<Client> ConnectedClients { private set; get;
}
public ConnectedClientsUpdatedEventArgs(IEnumerable<Client> connectedClients)
{
ConnectedClients = new List<Client>();
foreach (var client in connectedClients)
{
ConnectedClients.Add(client);
}
}
}
我们还需要另一个名为ChatEventArgs.cs的文件。将其添加到Events文件夹中,并实现以下内容:
public class ChatEventArgs : EventArgs
{
public string Message { private set; get;
}
public ChatEventArgs(string message)
{
Message = message;
}
}
此对象是每个聊天消息接收到的消息的包装器。现在,我们已经准备好实现我们的第一个展示者对象。
实现登录展示者(LoginPresenter)
创建一个名为LoginPresenter.cs的新文件,将其添加到Chat.Common项目中的Presenter文件夹,并实现以下内容:
public class LoginPresenter : BasePresenter
{
#region Private Properties
private ILoginView _view;
#endregion
#region IClientsListView
public interface ILoginView : IView
{
event EventHandler<Tuple<string, string>> Login;
event EventHandler<Tuple<string, string>> Register;
}
#endregion
#region Constructors
public LoginPresenter(ApplicationState state, INavigationService navigationService)
{
_navigationService = navigationService;
_state = state;
_webApiAccess = new WebApiAccess();
}
#endregion
#region Public Methods
public void SetView(ILoginView view)
{
_view = view;
_view.Login -= HandleLogin;
_view.Login += HandleLogin;
_view.Register -= HandleRegister;
_view.Register += HandleRegister;
}
#endregion
}
我们的LoginPresenter包含一个新的ILoginView接口,该接口为登录屏幕上出现的两个按钮提供了两个新的事件处理器。我们还包含了一个新的WebApiAccess对象,因为我们将在 Web API 上执行登录和注册操作。我们还需要另一个名为SetView的函数,这个函数将接受用户界面对象并注册ILoginView接口中指定的任何EventHandlers。现在让我们添加处理登录的函数:
#region Private Methods
private async void HandleLogin(object sender, Tuple<string, string> user)
{
if (!_view.IsInProgress)
{
_state.Username = user.Item1;
_view.IsInProgress = true;
if (user.Item2.Length >= 6)
{
var loggedIn = await _webApiAccess.LoginAsync(user.Item1, user.Item2, CancellationToken.None);
if (loggedIn)
{
var tokenContract = await _webApiAccess.GetTokenAsync(user.Item1, user.Item2, CancellationToken.None);
if (!string.IsNullOrEmpty(tokenContract.AccessToken))
{
var presenter = new ClientsListPresenter(_state, _navigationService, tokenContract.AccessToken);
_navigationService.PushPresenter(presenter);
}
else
{
_view.SetErrorMessage("Failed to register user.");
}
}
else
{
_view.SetErrorMessage("Invalid username or password.");
}
}
else
{
_view.SetErrorMessage("Password must be at least 6 characters.");
}
_view.IsInProgress = false;
}
}
HandleLogin函数将首先检查屏幕是否正在从另一个登录操作中继续进行;我们想要确保在任何时候只能发生一次登录或注册。首先,我们调用LoginAsync并检查用户是否存在于UserManager中,然后我们调用GetTokenAsync函数来检索将用于我们的HubConnection的访问令牌。如果两者都成功,我们使用NavigationService将ClientsListViewController推入。如果任一失败,我们使用SetErrorMessage函数来显示错误。
注意
我们通过传递给PushPresenter/ PopPresenter函数的展示者类型来控制导航堆栈。
现在让我们添加处理注册的函数:
private async void HandleRegister(object sender, Tuple<string, string> user)
{
// make sure only once can we be registering at any one time
if (!_view.IsInProgress)
{
_state.Username = user.Item1;
_view.IsInProgress = true;
if (user.Item2.Length >= 6)
{
var registerSuccess = await _webApiAccess.RegisterAsync(user.Item1, user.Item2, CancellationToken.None);
if (registerSuccess)
{
_view.SetErrorMessage("User successfully registered.");
}
}
else
{
_view.SetErrorMessage("Password must be at least 6 characters.");
}
_view.IsInProgress = false;
}
}
#endregion
与LoginAsync非常相似,但我们调用RegisterAsync并简单地等待调用完成并检查我们是否有 HTTP 状态码200 (OK)。
创建展示者和视图之间的连接
现在,我们转向用户界面设计,并展示我们如何设置展示者之间的链接。开发用户界面与为 iOS 和 Android 本地开发没有区别;与 MVP 的不同之处在于,我们在构造函数中初始化一个带有其相关展示者的视图。
让我们从在Chat.iOS项目中添加一个名为Views的新文件夹开始,添加一个名为LoginViewController.cs的新文件,并实现以下内容:
public class LoginViewController : UIViewController, LoginPresenter.ILoginView
{
#region Private Properties
private bool _isInProgress = false;
private LoginPresenter _presenter;
private UITextField _loginTextField;
private UITextField _passwordTextField;
private UIActivityIndicatorView _activityIndicatorView;
#endregion
#region Constructors
public LoginViewController(LoginPresenter presenter)
{
_presenter = presenter;
}
#endregion
}
我们从私有属性和构造函数开始,其中我们传递一个从AppDelegate创建的新LoginPresenter对象作为起始展示者。两个文本字段用于用户名和密码输入。我们将它们作为局部变量,因为我们将在多个函数中访问每个实例。我们还有一个UIActivityIndicatorView用于在登录和注册时显示进度。
让我们继续添加ViewDidLoad函数。我们将分几个部分来实现这个函数。首先,我们将设置展示者的视图并初始化所有 UI 元素并将它们添加到View中:
#region Public Methods
public override void ViewDidLoad()
{
base.ViewDidLoad();
View.BackgroundColor = UIColor.White;
_presenter.SetView(this);
var width = View.Bounds.Width;
var height = View.Bounds.Height;
Title = "Welcome";
var titleLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Text = "Chat",
Font = UIFont.FromName("Helvetica-Bold", 22),
TextAlignment = UITextAlignment.Center
};
_activityIndicatorView = new UIActivityIndicatorView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Color = UIColor.Black
};
var descriptionLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Text = "Enter your login name to join the chat room.",
Font = UIFont.FromName("Helvetica", 18),
TextAlignment = UITextAlignment.Center
};
_loginTextField = new UITextField()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Placeholder = "Username",
Font = UIFont.FromName("Helvetica", 18),
BackgroundColor = UIColor.Clear.FromHex("#DFE4E6"),
TextAlignment = UITextAlignment.Center
};
_passwordTextField = new UITextField()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Placeholder = "Password",
Font = UIFont.FromName("Helvetica", 18),
BackgroundColor = UIColor.Clear.FromHex("#DFE4E6"),
TextAlignment = UITextAlignment.Center
};
var buttonView = new UIView()
{
TranslatesAutoresizingMaskIntoConstraints = false
};
var loginButton = new UIButton(UIButtonType.RoundedRect)
{
TranslatesAutoresizingMaskIntoConstraints = false
};
loginButton.SetTitle("Login", UIControlState.Normal);
loginButton.TouchUpInside += (sender, e) =>
Login(this, new Tuple<string, string>(_loginTextField.Text, _passwordTextField.Text));
var registerButton = new UIButton(UIButtonType.RoundedRect)
{
TranslatesAutoresizingMaskIntoConstraints = false
};
registerButton.SetTitle("Register", UIControlState.Normal);
registerButton.TouchUpInside += (sender, e) =>
Register(this, new Tuple<string, string>(_loginTextField?.Text, _passwordTextField?.Text));
Add(titleLabel);
Add(descriptionLabel);
Add(_activityIndicatorView);
Add(_loginTextField);
Add(_passwordTextField);
Add(buttonView);
buttonView.Add(loginButton);
buttonView.Add(registerButton);
}
#endregion
这是一段很大的代码块,但我们正在创建很多 UI 元素。所有元素都将 TranslatesAutoresizingMaskIntoConstraints 设置为 false,以便进行 **NSLayout**。看看我们如何将 ILoginView 实现与登录和 RegisterEventHandlers 集成,因为它们连接到每个按钮的 TouchUpInside 事件。
现在,让我们开始构建 NSLayoutConstraints。将以下内容添加到 ViewDidLoad 函数的底部:
小贴士
我们正在使用之前章节中使用的 DictionaryViews 对象。创建一个名为 Extras 的新文件夹,并将此对象添加到 Extras 文件夹中。
var views = new DictionaryViews()
{
{"titleLabel", titleLabel},
{"descriptionLabel", descriptionLabel},
{"loginTextField", _loginTextField},
{"passwordTextField", _passwordTextField},
{"loginButton", loginButton},
{"registerButton", registerButton},
{"activityIndicatorView", _activityIndicatorView},
{"buttonView", buttonView}
};
buttonView.AddConstraints(
NSLayoutConstraint.FromVisualFormat("V:|-[registerButton]-|", NSLayoutFormatOptions.DirectionLeftToRight, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("V:|-[loginButton]-|", NSLayoutFormatOptions.DirectionLeftToRight, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-[registerButton]-30-[loginButton]-|", NSLayoutFormatOptions.DirectionLeftToRight, null, views))
.ToArray());
View.AddConstraints(
NSLayoutConstraint.FromVisualFormat("V:|-100-[titleLabel(50)]-[descriptionLabel(30)]-10-[loginTextField(30)]-10-[passwordTextField(30)]-10-[buttonView]", NSLayoutFormatOptions.DirectionLeftToRight, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("V:|-100-[activityIndicatorView(50)]-[descriptionLabel(30)]-10-[loginTextField(30)]-10-[passwordTextField(30)]-10-[buttonView]", NSLayoutFormatOptions.DirectionLeftToRight, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-10-[titleLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:[activityIndicatorView(30)]-10-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-10-[descriptionLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-30-[loginTextField]-30-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-30-[passwordTextField]-30-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(new[] { NSLayoutConstraint.Create(buttonView, NSLayoutAttribute.CenterX, NSLayoutRelation.Equal, View, NSLayoutAttribute.CenterX, 1, 1)
})
.ToArray());
约束将 buttonView 定位于屏幕中心水平位置;每个按钮内部将水平并排放置。其余布局非常直观。我们只是将剩余元素垂直堆叠在页面底部。UIActivityIndicatorView 将位于屏幕右上角,靠近 TitleLabel。当我们尝试运行应用程序时,其余布局将更加合理。
最后,我们添加剩余的界面实现;我们需要 ILoginView 接口中的登录和 Register。我们还需要 IsInProgress 布尔值和 SetErrorMessage 函数;这将创建一个新的 UIAlertView 显示错误消息。我们还重写了 IsInProgress 的获取和设置,以控制 UIActivityIndicatorView 的开始和停止动画:
#region ILoginView implementation
public event EventHandler<Tuple<string, string>> Login;
public event EventHandler<Tuple<string, string>> Register;
#endregion
#region IView implementation
public void SetErrorMessage(string message)
{
var alert = new UIAlertView()
{
Title = "Chat",
Message = message
};
alert.AddButton("OK");
alert.Show();
}
public bool IsInProgress
{
get
{
return _isInProgress;
}
set
{
if (value == _isInProgress)
{
return;
}
// we control the activity view when we set 'IsInProgress'
if (value)
{
_activityIndicatorView.StartAnimating();
}
else
{
_activityIndicatorView.StopAnimating();
}
_isInProgress = value;
}
}
#endregion
我们的第一个视图和演示者之间的链接不如 Xamarin.Forms 中的 MVVM 绑定上下文 清晰,但优点是没有在本地用户界面和要显示的数据之间有中间渲染层。
构建登录活动
让我们回到 Chat.Droid 项目;在我们创建 Activity 之前,我们需要使用一个新的 XML 表单创建布局。在 资源 | 布局 中添加一个名为 LoginView.xml 的新文件,并实现以下内容:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:id="@+id/tableLayout"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="vertical"
android:gravity="center"
android:background="#FFFFFF">
<TextView
android:id="@+id/titleTextView"
android:text="Chat"
android:fontFamily="helvetica"
android:textStyle="bold"
android:textSize="22dp"
android:textColor="#000000"
android:paddingBottom="20dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/descriptionTextView"
android:text="Enter your login name to join the chat room."
android:fontFamily="helvetica"
android:textColor="#000000"
android:paddingBottom="20dp"
android:layout_centerInParent="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<EditText
android:id="@+id/usernameField"
android:textColor="#000000"
android:layout_width="fill_parent"
android:layout_height="50dp"
android:paddingBottom="20dp"
android:hint="Enter Username" />
<EditText
android:id="@+id/passwordField"
android:textColor="#000000"
android:layout_width="fill_parent"
android:layout_height="50dp"
android:hint="Enter Password" />
<LinearLayout
android:id="@+id/tableLayout"
android:gravity="center"
android:layout_width="fill_parent"
android:layout_height="150dp"
android:orientation="horizontal"
android:background="#FFFFFF">
<Button
android:id="@+id/registerButton"
android:text="Register"
android:textColor="#417BB5"
android:background="@android:color/transparent"
android:layout_height="50dp"
android:layout_width="100dp" />
<Button
android:id="@+id/loginButton"
android:text="Login"
android:textColor="#417BB5"
android:background="@android:color/transparent"
android:paddingLeft="20dp"
android:layout_height="50dp"
android:layout_width="100dp" />
</LinearLayout>
</LinearLayout>
XMLlayout 将页面垂直堆叠,两个按钮并排放置。
在 Xamarin.Studio 中快速检查布局的方法是点击 设计师 窗口:

现在,让我们创建一个名为 Views 的新文件夹,添加一个名为 LoginActivity.cs 的新文件,并实现第一个部分:
[Activity(MainLauncher = true, Label = "Chat", ScreenOrientation = ScreenOrientation.Portrait)]
public class LoginActivity : Activity, LoginPresenter.ILoginView
{
#region Private Properties
private bool _isInProgress = false;
private bool _dialogShown = false;
private LoginPresenter _presenter;
private EditText _loginField;
private EditText _passwordField;
private ProgressDialog progressDialog;
#endregion
#region Protected Methods
protected override void OnCreate(Bundle bundle)
{
base.OnCreate(bundle);
SetContentView(Resource.Layout.LoginView);
progressDialog = new ProgressDialog(this);
progressDialog.SetMessage("Loading...");
progressDialog.SetCancelable(false);
_loginField = FindViewById<EditText>(Resource.Id.usernameField);
_passwordField = FindViewById<EditText>(Resource.Id.passwordField);
var registerButton = FindViewById<Button>(Resource.Id.registerButton);
registerButton.Touch += (sender, e) =>
Register(this, new Tuple<string, string>(_loginField.Text, _passwordField.Text));
var loginButton = FindViewById<Button>(Resource.Id.loginButton);
loginButton.Touch += (sender, e) =>
Login(this, new Tuple<string, string>(_loginField.Text, _passwordField.Text));
var app = ChatApplication.GetApplication(this);
var state = new ApplicationState();
_presenter = new LoginPresenter(state, new NavigationService(app));
_presenter.SetView(this);
app.CurrentActivity = this;
}
#endregion
由于我们已经在演示者中有了 UI 逻辑,因此构建 LoginActivity 的界面要容易得多,因为所有答案都在演示者中。这是使用 MVP 模式进行代码共享的优势。
在我们的 OnCreate() 函数中,我们将首先将 ContentView 设置为我们之前创建的 XMLlayout。然后我们将按钮的 Touch 事件注册到 ILoginView 接口,这与 iOS 版本的 TouchUpInside 事件非常相似。然后我们从 GetApplication 函数中检索应用程序。我们还创建了一个 ApplicationState 实例,并创建了一个新的 LoginPresenter。
我们还必须添加ILoginView和IView接口的要求。SetErrorMessage将使用AlertDialog.Builder框架创建与 iOS 版本相同的弹出窗口。我们只为此对话框设置一个按钮,当我们按下****OK**时,它将简单地关闭对话框。
#region ILoginView implementation
public event EventHandler<Tuple<string, string>> Login;
public event EventHandler<Tuple<string, string>> Register;
#endregion
#region IView implementation
public void SetErrorMessage(string message)
{
if (!_dialogShown)
{
_dialogShown = true;
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder
.SetTitle("Chat")
.SetMessage(message)
.SetNeutralButton("Ok", (sender, e) => { _dialogShown = false ;})
.Show();
}
}
public bool IsInProgress
{
get
{
return _isInProgress;
}
set
{
if (value == _isInProgress)
{
return;
}
// we control the activity view when we set 'IsInProgress'
if (value)
{
progressDialog.Show();
}
else
{
progressDialog.Dismiss();
}
_isInProgress = value;
}
}
#endregion
}
看看结构是否与 iOS 完全相同?
我们只需要独立匹配每个平台的 UI 元素。我们活动的最后一部分是OnResume函数。此函数将在Application中重置CurrentActivity:
小贴士
每次活动恢复时重置CurrentActivity非常重要,否则导航服务不会在正确的Activity上推送/弹出。
protected override void OnResume()
{
base.OnResume();
var app = ChatApplication.GetApplication(this);
app.CurrentActivity = this;
if (_presenter != null)
{
_presenter.SetView(this);
}
}
#endregion
太棒了!现在我们已经创建了第一个屏幕、演讲者,并将其与导航服务链接起来。让我们回到Chat.iOS项目,构建我们应用程序的下一个屏幕。
实现客户端列表演讲者
创建一个名为ClientsListPresenter.cs的新文件,将其添加到Chat.Common项目中的Presenter文件夹,并实现以下内容:
public class ClientsListPresenter : BasePresenter
{
#region Private Properties
private IClientsListView _view;
#endregion
#region IClientsListView
public interface IClientsListView : IView
{
event EventHandler<ClientSelectedEventArgs> ClientSelected;
void NotifyConnectedClientsUpdated(IEnumerable<Client> clients);
}
#endregion
#region Constructors
public ClientsListPresenter(ApplicationState state, INavigationService navigationService,
string accessToken)
{
_navigationService = navigationService;
_state = state;
_state.AccessToken = accessToken;
InitSignalR(accessToken).ConfigureAwait(false);
}
#endregion
}
我们已声明一个新的IClientsListView接口,专门针对当前的UIViewController(这必须为每个屏幕执行)。它简单地扩展了IView接口,并为我们的UITableView中的选中项添加了一个额外的事件处理器。然后我们有我们的构造函数,我们必须传递一个ApplicationState、NavigationService和一个访问令牌。我们还初始化了SignalRClient:ConfigureAwait函数设置为 false,因为我们不希望等待此任务完成。
现在,我们需要添加另一个名为SetView的函数。这将采取用户界面操作对象并注册IClientsListView接口中指定的任何EventHandlers。我们还调用 Web API 以检索连接到ChatHub的当前客户端。我们还指定不通过ConfigureAwait函数等待此任务。
在每个将响应来自SignalRClient实时数据更新的SetView中,我们必须重新注册到OnDataReceived事件处理器,以便调用正确的演讲者函数HandleSignalRDataReceived:
#region Public Methods public void SetView(IClientsListView view)
{
_view = view;
_signalRClient.OnDataReceived -= HandleSignalRDataReceived;
_signalRClient.OnDataReceived += HandleSignalRDataReceived;
_view.ClientSelected -= HandleClientSelected;
_view.ClientSelected += HandleClientSelected;
ConnectedClientsUpdated -= HandleConnectedClientsUpdated;
ConnectedClientsUpdated += HandleConnectedClientsUpdated;
GetAllConnectedClients().ConfigureAwait(false);
}
#endregion
演讲者还可以有与SetView函数相反的ReleaseView函数。它将负责在屏幕消失时处理事件处理器。这确保了我们不会在之前的任何页面上有事件在它们不可见时执行工作。在SetView函数下添加以下内容:
public void ReleaseView()
{
_signalRClient.OnDataReceived -= HandleSignalRDataReceived;
}
现在让我们添加Signout函数。当用户想要从ChatHub断开连接(当用户离开ClientsListViewController)时,将调用此函数:
public void Signout()
{
_signalRClient.Disconnect();
_navigationService.PopPresenter(true);
}
让我们添加两个额外的函数:HandleClientSelected将使用INavigationService将下一个屏幕推入堆栈,另一个函数HandleConnectedClientsUpdated将调用用户界面对象中的本地实现。我们还将使用 Linq 过滤客户列表,包括所有其他客户但排除当前用户:
#region Private Methods
private void HandleClientSelected(object sender, ClientSelectedEventArgs e)
{
var presenter = new ChatPresenter(_state, _navigationService, e.Client, _signalRClient);
_navigationService.PushPresenter(presenter);
}
private void HandleConnectedClientsUpdated(object sender,
ConnectedClientsUpdatedEventArgs e)
{
_view.NotifyConnectedClientsUpdated(e.ConnectedClients
.Where(x => !x.Username.ToLower()
.Contains(_state.Username.ToLower())));
}
#endregion
由于我们知道我们需要在ClientsListView屏幕上使用UITableView,我们需要创建一个TableSource对象来显示所有连接到ChatHub的客户。我们还需要一个模型对象来存储每个Client要显示的数据。
首先,在Chat.Common项目中创建一个名为Model的新文件夹,添加一个名为Client.cs的新文件,并实现以下内容:
public class Client { public string Username; }
对于每个单元格,我们只将显示一个文本标签,显示连接客户的用户名。现在让我们添加一个名为ClientsTableSource.cs的新文件,并从以下内容开始:
public class ClientsTableSource : UITableViewSource
{
#region Public Properties
public event EventHandler<Client> ItemSelected;
#endregion
#region Private Properties
private List<Client> _clients;
string CellIdentifier = "ClientCell";
#endregion
#region Constructors
public ClientsTableSource ()
{
_clients = new List<Client> ();
}
#endregion
}
我们需要一个私有的List来存储最新连接的客户,我们的CellIdentifier标签设置为ClientCell,并且我们有一个EventHandler用于处理从UITableView中选中的单元格事件。
每当这些事件之一从TableSource触发时,我们将在我们的ClientsListPresenter演示者中触发事件处理器。现在让我们实现UITableViewSource类所需的其余重写方法:
#region Methods
public void UpdateClients(IEnumerable<Client> clients)
{
foreach (var client in clients)
{
_clients.Add (client);
}
}
public override nint NumberOfSections (UITableView tableView)
{
return 1;
}
public override nint RowsInSection (UITableView tableview, nint section)
{
return _clients.Count;
}
public override void RowSelected (UITableView tableView, NSIndexPath indexPath)
{
if (ItemSelected != null)
{
ItemSelected (this, _clients[indexPath.Row]);
}
tableView.DeselectRow (indexPath, true);
}
public override nfloat GetHeightForRow (UITableView tableView, NSIndexPath indexPath)
{
return 80;
}
public override UITableViewCell GetCell (UITableView tableView, NSIndexPath indexPath)
{
UITableViewCell cell = tableView.DequeueReusableCell(CellIdentifier);
var client = _clients[indexPath.Row];
if (cell == null)
{
cell = new UITableViewCell(UITableViewCellStyle.Default, CellIdentifier);
}
cell.TextLabel.Text = client.Ip;
return cell;
}
#endregion
我们的GetCell函数将使用默认的UITableViewCellStyle,文本将设置为Client对象的用户名。我们的RowSelected函数将触发我们的自定义EventHandlerItemSelected。我们将在该EventHandler上注册一个代理以触发相关的演示者Event。最后,我们的UpdateClients将在收到客户端计数变化时的代理事件时被调用。
创建 ClientListViewController
现在我们将转向用户界面设计,并演示我们如何设置演示者之间的链接。开发用户界面与为 iOS 和 Android 本地开发没有不同;与 MVP 的不同之处在于我们在构造函数中初始化一个带有其相关演示者的视图。
让我们从向Chat.iOS项目添加一个名为Views的新文件夹开始,添加一个名为ClientsListViewController.cs的新文件,并实现以下内容:
public class ClientsListViewController : UIViewController, ClientsListPresenter.IClientsListView
{
#region Private Properties
private UITableView _tableView;
private ClientsTableSource _source;
private ClientsListPresenter _presenter;
private UIActivityIndicatorView _activityIndicatorView;
#endregion
#region Constructors
public ClientsListViewController(ClientsListPresenter presenter)
{
_presenter = presenter;
_source = new ClientsTableSource();
_source.ItemSelected += (sender, e) =>
{
if (ClientSelected != null)
{
ClientSelected(this, new ClientSelectedEventArgs(e));
}
};
}
#endregion
}
注意我们是如何在UIViewController的构造函数中传递演示者的?
我们将对添加到导航服务中的每个视图都执行此操作。
在构造函数中,我们还注册了itemSelected事件来触发我们的演示者接口事件。让我们添加以下内容:
#region Public Methods
public override void ViewDidLoad()
{
base.ViewDidLoad();
// Perform any additional set up after loading the view, typically from a nib.
UIBarButtonItem backButton = new UIBarButtonItem("< Back", UIBarButtonItemStyle.Bordered, HandleSignout);
NavigationItem.SetLeftBarButtonItem(backButton, false);
View.BackgroundColor = UIColor.White;
_presenter.SetView(this);
var width = View.Bounds.Width;
var height = View.Bounds.Height;
Title = "Clients";
var titleLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Text = "Connected Clients",
Font = UIFont.FromName("Helvetica-Bold", 22),
TextAlignment = UITextAlignment.Center
};
var descriptionLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Text = "Select a client you would like to chat with",
Font = UIFont.FromName("Helvetica", 18),
TextAlignment = UITextAlignment.Center
};
_tableView = new UITableView(new CGRect(0, 0, width, height))
{
TranslatesAutoresizingMaskIntoConstraints = false
};
_tableView.AutoresizingMask = UIViewAutoresizing.All;
_tableView.Source = _source;
Add(titleLabel);
Add(descriptionLabel);
Add(_tableView);
var views = new DictionaryViews()
{
{"titleLabel", titleLabel},
{"descriptionLabel", descriptionLabel},
{"tableView", _tableView},
};
View.AddConstraints(
NSLayoutConstraint.FromVisualFormat("V:|-100-[titleLabel(30)]-[descriptionLabel(30)]-[tableView]|", NSLayoutFormatOptions.DirectionLeftToRight, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("H:|[tableView]|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-10-[titleLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-10-[descriptionLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, views)) .ToArray());
}
#endregion
在 ViewDidLoad 函数中,我们总是会调用演示者类上的 SetView,并将视图本身传递给演示者。我们还将在这个屏幕上添加另一个小技巧来重写 navbar 返回按钮。我们必须创建一个 UIBarButtonItem,它将被设置为导航栏的左侧按钮。当我们实例化此项目时,当按下此按钮时将调用 HandleSignout 函数。让我们将其添加到 UIViewController:
public async void HandleSignout(object sender, EventArgs e)
{
bool accepted = await ShowAlert("Chat", "Would you like to signout?");
if (accepted)
{
_presenter.Signout();
}
}
该函数将显示一个警告并等待用户提供响应。在这种情况下,将是 "是" 或 "否"。我们将添加另一个函数 ShowAlert(),该函数将使用 TaskCompletionSource 框架来允许我们从 UIAlertView 等待响应。
TaskCompletionSource 框架
ShowAlert 函数将实例化一个 TaskCompletionSource 的新实例,其类型为 bool。然后我们使用 UIApplication.SharedApplication 在主线程上调用操作,然后返回 TaskCompletionSource 的 Task 对象。这意味着我们可以等待任务返回。当我们创建 UIAlertView 时,我们将设置对话框的 Clicked 事件以调用 TaskCompletionSource 的 SetResult 函数,这样 Task 就不会完成,直到这个点击事件发生:
public Task<bool> ShowAlert(string title, string message)
{
var tcs = new TaskCompletionSource<bool>();
UIApplication.SharedApplication.InvokeOnMainThread(new Action(() =>
{
UIAlertView alert = new UIAlertView(title, message, null, NSBundle.MainBundle.LocalizedString("Cancel", "Cancel"),
NSBundle.MainBundle.LocalizedString("OK", "OK"));
alert.Clicked += (sender, buttonArgs) =>
tcs.SetResult(buttonArgs.ButtonIndex != alert.CancelButtonIndex);
alert.Show();
}));
return tcs.Task;
}
现在我们已经重写了返回按钮,当用户尝试在客户端列表屏幕上点击返回以返回登录时,将出现 UIAlertView,询问用户是否想要注销(这意味着用户将从 ChatHub 断开连接)。如果用户按下 是,我们将调用 ClientsListPresenter 上的 Signout 函数。
现在,让我们回到 ViewDidLoad 函数并添加 NSLayoutConstraints 以构建屏幕:
View.AddConstraints(NSLayoutConstraint.FromVisualFormat("V:|-100-[titleLabel(30)]-[descriptionLabel(30)]-[tableView]|", NSLayoutFormatOptions.DirectionLeftToRight, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("H:|[tableView]|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-10-[titleLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-10-[descriptionLabel]-10-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.ToArray());
我们将所有元素垂直堆叠,占据整个屏幕宽度并添加填充。
最后,我们还想添加 ViewDidUnload 函数,以便我们可以从 SignalRClient 中移除 OnDataReceived 事件:
public override void ViewDidUnload()
{
base.ViewDidUnload();
_presenter.ReleaseView();
}
创建 ClientsListActivity
让我们再次回到 Chat.Droid 项目。创建一个名为 Views 的新文件夹,添加一个名为 ClientsListView.cs 的新文件,并实现以下内容:
[Activity(Label = "Chat Room", Icon = "@drawable/icon", ScreenOrientation = ScreenOrientation.Portrait)]
public class ClientsListActivity : ListActivity, ClientsListPresenter.IClientsListView
{
#region Private Properties
private ClientsListPresenter _presenter;
private ClientsListAdapter _adapter;
private bool _dialogShown = false;
#endregion
#region Protected Methods
protected override void OnCreate(Bundle bundle)
{
base.OnCreate(bundle);
ListView.SetBackgroundColor(Color.White);
var app = ChatApplication.GetApplication(this);
app.CurrentActivity = this;
_presenter = app.Presenter as ClientsListPresenter;
_presenter.SetView(this);
_adapter = new ClientsListAdapter(this);
ListAdapter = _adapter;
}
protected override void OnResume()
{
base.OnResume();
var app = ChatApplication.GetApplication(this);
app.CurrentActivity = this;
if (_presenter != null)
{
_presenter.SetView(this);
}
}
#endregion
}
对于 ClientsListActivity 的第一个部分,让我们看看 OnCreate 重写方法。我们将从 ChatApplication 实例开始,将当前 Activity 设置为 ClientsListView 活动。然后实例化一个新的 ClientsListPresenter,添加状态,并添加一个新的 NavigationService。我们还将将演示者的视图对象设置为 ClientsListView。最后,我们简单地实例化一个新的 ClientsListAdapter 并将其设置为 Activity 的 ListAdapter,因为我们正在继承 ListActivity。我们将有一个与 iOS 不同的布局,只在这个屏幕上显示 ListView 以演示 ListActivity;因此,我们不需要为这个 Activity 的布局创建 XMLsheet。
OnResume 函数与 LoginActivity 相同;我们必须保持向用户显示的当前 Activity。我们还想覆盖 OnPause 函数,以便在 ClientsListPresenter 上调用 ReleaseView,这样我们就可以从 SignalRClient 的 OnDataReceived 属性中移除 EventHandler。这确保了当屏幕不可见时,我们不会调用 HandleSignalRDataReceived。
protected override void OnPause()
{
base.OnPause();
if (_presenter != null)
{
_presenter.ReleaseView();
}
}
现在我们来添加 IClientsListView 和 IView 的实现。NotifyConnectedClientsUpdated 将会在 ListAdapter 上调用 UpdateClients 函数,并且我们必须在主线程上传播 NotifyDataSetChanged,因为我们正在对 ListView 进行数据更改:
#region IClientsListView implementation
public event EventHandler<ClientSelectedEventArgs> ClientSelected;
public void NotifyConnectedClientsUpdated(IEnumerable<Client> clients)
{
if (_adapter != null)
{
_adapter.UpdateClients(clients);
// perform action on UI thread
Application.SynchronizationContext.Post(state =>
{
_adapter.NotifyDataSetChanged();
}, null);
}
}
#endregion
#region IView implementation
public void SetErrorMessage(string message)
{
if (!_dialogShown)
{
_dialogShown = true;
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder
.SetTitle("Chat")
.SetMessage(message)
.SetNeutralButton("Ok", (sender, e) => { _dialogShown = false; })
.Show();
} }
public bool IsInProgress { get; set;
}
#endregion
SetErrorMessage() 将会启动一个类似于 iOS 的对话框,使用 AlertDialog.Builder 框架。在这种情况下,我们只需要设置原始按钮,因为我们只需要在对话框中有一个按钮。
覆盖 OnBackPressed 活动
在我们的 iOS 实现中,我们集成了一个覆盖导航返回按钮的功能,所以当用户离开 ClientListViewController 时,我们会询问用户是否想要从 ChatHub 中注销。我们在这里也将做同样的事情,但是在 Android 平台上。我们将从 AlertDialog.Builder 框架构建警报:
public override void OnBackPressed()
{
//Put up the Yes/No message box
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder
.SetTitle("Chat")
.SetMessage("Would you like to signout?")
.SetNegativeButton("No", (sender, e) => { })
.SetPositiveButton("Yes", (sender, e) =>
{
_presenter.Signout();
})
.Show();
}
我们从实例化一个新的构建器对象开始,该对象必须只接受 Activity 上下文作为唯一参数。然后我们设置对话框的标题和消息,并为 "Yes" 和 "No" 选择设置两个按钮。只有当用户选择 "Yes" 时,才会通过调用与 iOS 相同的 Signout 来执行操作。
我们这个 Activity 的最后一部分是覆盖 OnListItemClick。当列表中的某个项目被选中时,我们希望触发接口中指定的 ClientSelected 事件,这样我们就可以将此事件逻辑连接到 ClientsListPresenter:
protected override void OnListItemClick(ListView l, Android.Views.View v, int position, long id)
{
var item = _adapter[position];
if (ClientSelected != null)
{
ClientSelected(this, new ClientSelectedEventArgs(item));
}
}
#endregion
构建 ListAdapter
在我们构建 ListAdapter 之前,我们需要为 CustomCell 创建另一个 AXML 表格,向 Resources | layout 文件夹中添加另一个文件,名为 CustomCell.xml,并实现以下内容:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:weightSum="4">
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
这是一个另一个简单的布局,其中包含一个包裹在 LinearLayout 中的 TextView。TextView 将显示每个 Client 的 ConnectionId。
现在我们回到 ListAdapter。在 Views 文件夹内,添加另一个名为 ClientsListAdapter.cs 的文件,并实现以下内容:
public class ClientsListAdapter : BaseAdapter<Client>
{
private List<Client> _clients;
private Activity _context;
public ClientsListAdapter(Activity context) : base()
{
_context = context;
_clients = new List<Client>();
}
}
首先,我们只是创建一个新的类,该类继承自 BaseAdapter 类,并将其转换为 Client 对象。我们还有一个私有的 List,它将存储从 SignalRClient 获取到的客户端,最后我们有当前的 Activity 上下文。现在让我们添加来自 BaseAdapter 的所需覆盖函数:
public override Client this[int position]
{
get
{
return _clients[position];
}
}
public override Java.Lang.Object GetItem (int position)
{
return null;
}
public override long GetItemId(int position)
{
return position;
}
public override int Count
{
get
{
return _clients.Count;
}
}
public override View GetView(int position, View convertView, ViewGroup parent)
{
View view = convertView; // re-use an existing view, if one is available
if (view == null)
{
// otherwise create a new one
view = _context.LayoutInflater.Inflate(Resource.Layout.CustomCell, null);
}
// set labels
var connectionIdTextView = view.FindViewById<TextView>
(Resource.Id.username);
connectionIdTextView.Text = _clients[position].Username;
return view;
}
第一个覆盖是实现对_clientslist的索引引用。所有覆盖函数都与我们实现的相同,如第一章中所述,构建画廊应用程序。让我们将注意力转向GetView函数;我们只是使用LayoutInflater框架创建一个新的CustomCell布局(这将接受任何 AXML 文件并创建视图的新实例)。
然后,现在我们已经有了我们的新视图,我们将设置CustomCell视图中的TextView对象的Text属性为Client对象中的Username。
最后,我们的最后一步是添加一个名为UpdateClients的另一个函数(如展示者中指定)。这将会简单地接受一个新的IEnumerable的Clients,并且List将会相应地更新:
public void UpdateClients(IEnumerable<Client> clients)
{
foreach (var client in clients)
{
_clients.Add(client);
}
}
在展示者类的完全指导下,看看我们是如何快速开发 Android 界面的。
小贴士
在我们能够测试与服务器Hub的连接之前,我们必须在命令提示符中使用netsh对application.config和http.sys进行更改。遵循第五章中在本地托管 Web API 项目的部分,构建股票列表应用程序。
你可以尝试测试第一页。启动服务器Hub,并观察列表在连接或断开新客户端时更新。在这个例子中,使用多个在不同设备上运行的应用程序实例进行测试是一个很好的测试方法。
构建聊天展示者
现在我们转向下一个屏幕;这将是我们聊天窗口,我们将在此窗口中传递连接到服务器Hub的不同客户端之间的消息。我们的第一步是构建ChatPresenter:
public class ChatPresenter : BasePresenter
{
#region Private Properties
private Client _client;
private IChatView _view;
#endregion
#region IChatView
public interface IChatView : IView
{
void NotifyChatMessageReceived(string message);
}
#endregion
}
我们将首先继承BasePresenter类。它将包括两个private属性,一个用于从上一个ClientListView屏幕中选择的Client,另一个用于IChatView接口。IChatView接口继承自IView接口,它将包括一个用于处理从接收Client接收到的消息的功能。
让我们实现以下内容:
#region Constructors
public ChatPresenter(ApplicationState state, INavigationService navigationService, Client client)
{
_navigationService = navigationService;
_state = state;
_client = client;
}
#endregion
#region Public Methods
public void SetView(IChatView view)
{
_view = view;
ChatReceived -= HandleChatReceived;
ChatReceived += HandleChatReceived;
}
public async Task SendChat(string message)
{
await _signalRClient.SendMessageToClient(_client.ConnectedId, message);
}
#endregion
#region Private Methods
private void HandleChatReceived(object sender, ChatEventArgs e)
{
_view.NotifyChatMessageReceived(e.Message);
}
#endregion
它与ClientsListPresenter的设置相同;我们的SetView函数将接受原生视图对象并注册事件。我们还有一个名为SendChat的另一个函数,它将在Hub上调用SendChat函数。不要忘记ReleaseView函数;这将与ClientsListPresenter完全相同:
public void ReleaseView()
{
_signalRClient.OnDataReceived -= HandleSignalRDataReceived;
}
现在我们已经构建了所有展示者对象,我们需要对导航服务实现进行小更新,以允许导航到其他屏幕。打开 Android 的NavigationService.cs,并在PushPresenter函数中更新if语句为以下内容:
if (presenter is LoginPresenter)
{
intent = new Intent(_application.CurrentActivity, typeof(LoginActivity));
}
else if (presenter is ClientsListPresenter)
{
intent = new Intent(_application.CurrentActivity, typeof(ClientsListActivity));
}
else if (presenter is ChatPresenter)
{
intent = new Intent(_application.CurrentActivity, typeof(ChatActivity));
}
对于 iOS 的NavigationService.cs,将 if 语句更新为以下内容:
if (presenter is LoginPresenter)
{
var viewController = new LoginViewController(presenter as LoginPresenter);
_navigationController.PushViewController(viewController, true);
}
else if (presenter is ClientsListPresenter)
{
var viewController = new ClientsListViewController(presenter as ClientsListPresenter);
_navigationController.PushViewController(viewController, true);
}
else if (presenter is ChatPresenter)
{
var viewController = new ChatViewController(presenter as ChatPresenter);
_navigationController.PushViewController(viewController, true);
}
构建 iOS 聊天视图
在Chat.iOS项目的Views项目中添加一个名为ChatViewController的新文件,并实现以下内容:
public class ChatViewController : UIViewController, ChatPresenter.IChatView {
#region Private Properties
private ChatPresenter _presenter;
private UITextField _chatField;
private UIScrollView _scrollView;
private int _currentTop = 20;
private nfloat _width;
#endregion
#region Constructors
public ChatViewController(ChatPresenter presenter)
{
_presenter = presenter;
}
#endregion
}
我们有多个 Private 属性,一个用于演示者,一个用于本地的 UITextField。我们需要这个 UI 对象是本地的,因为我们需要提取 Text 值通过 SignalRClient 发送,我们还需要 UIScrollView 是本地的,这样我们就可以更改内容大小并添加 ChatView 对象。整数用于记录所有聊天消息在屏幕上显示的当前顶部(y 轴 + 高度)。最后,剩余的 nfloat 用于记录屏幕的高度和宽度。
注意
我们将在后续的类函数中看到所有这些变量的使用。
现在让我们添加 ViewDidLoad 函数来构建用户界面:
#region Public Methods
public override void ViewDidLoad()
{
base.ViewDidLoad();
Title = "Chat Room";
_presenter.SetView(this);
View.BackgroundColor = UIColor.White;
_width = View.Bounds.Width;
var _sendButton = new UIButton(UIButtonType.RoundedRect)
{
TranslatesAutoresizingMaskIntoConstraints = false
};
_sendButton.SetTitle("Send", UIControlState.Normal);
_sendButton.TouchUpInside += HandleSendButton;
_chatField = new UITextField()
{
TranslatesAutoresizingMaskIntoConstraints = false,
BackgroundColor = UIColor.Clear.FromHex("#DFE4E6"),
Placeholder = "Enter message"
};
_scrollView = new UIScrollView()
{
TranslatesAutoresizingMaskIntoConstraints = false,
};
Add(_chatField);
Add(_sendButton);
Add(_scrollView);
var views = new DictionaryViews()
{
{"sendButton", _sendButton},
{"chatField", _chatField},
{"scrollView", _scrollView},
};
this.View.AddConstraints(
NSLayoutConstraint.FromVisualFormat("V:|-68-[chatField(60)]", NSLayoutFormatOptions.DirectionLeftToRight, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("V:|-62-[sendButton(60)]-20-[scrollView]|", NSLayoutFormatOptions.DirectionLeftToRight, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-5-[chatField]-[sendButton(60)]-5-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.Concat(NSLayoutConstraint.FromVisualFormat("H:|[scrollView]|", NSLayoutFormatOptions.AlignAllTop, null, views))
.ToArray());
}
#endregion
聊天屏幕将包含一个 UITextField、一个 UIButton 和一个 UIScrollView。按钮用于通知将 UITextField 的当前 Text 值发送到服务器 Hub,我们的 UIScrollView 将包含来自每个客户端发布的所有消息。
我们还希望添加 ViewDidUnload() 函数,以便我们可以从 SignalRClient 上移除 OnDataReceived 事件:
public override void ViewDidUnload()
{
base.ViewDidUnload();
_presenter.ReleaseView();
}
然后添加 IView 实现:
#region IView implementation
public void SetMessage(string message)
{
var alert = new UIAlertView()
{
Title = "Chat",
Message = message
};
alert.AddButton("OK");
alert.Show();
}
public bool IsInProgress { get; set; } #endregion
IView 的实现与 ClientsListViewController 相同。
让我们创建一个名为 ChatBoxView.cs 的新文件,并将其添加到 Views 文件夹中。我们将为每条聊天消息创建一个这样的新文件:
public class ChatBoxView : UIView
{
private UILabel messageLabel;
public ChatBoxView(string message)
{
Layer.CornerRadius = 10;
messageLabel = new UILabel()
{
TranslatesAutoresizingMaskIntoConstraints = false,
Text = message
};
Add(messageLabel);
var views = new DictionaryViews()
{
{"messageLabel", messageLabel},
};
AddConstraints(NSLayoutConstraint.FromVisualFormat("V:|[messageLabel]|", NSLayoutFormatOptions.AlignAllTop, null, views)
.Concat(NSLayoutConstraint.FromVisualFormat("H:|-5-[messageLabel]-5-|", NSLayoutFormatOptions.AlignAllTop, null, views))
.ToArray());
}
}
这是一个非常简单的对象,包含一个用于聊天消息的 UILabel。我们还使用 NSAutoLayout 将这个标签的高度和宽度设置为 UIView 的高度和宽度。我们还使用 Layer 将角落的圆角设置为 5。
如果你曾在任何 iOS 设备上使用过 SMS 应用程序,你会看到我们有两种颜色,用于区分你和你要交谈的人。我们将在我们的应用程序中做同样的事情,但我们将使用自定义的十六进制颜色,而不是使用 UIColor 接口的标准颜色。
扩展 UIColor 框架
在本节中,我们将应用一种常见的扩展标准 iOS 类的技术。在 UIColor 类中,没有用于将十六进制字符串应用于确定颜色的函数,所以让我们在顶部添加这个功能。创建一个名为 Extensions 的新文件夹,添加一个名为 UIColorExtensions.cs 的新文件,并实现以下内容:
public static class UIColorExtensions
{
public static UIColor FromHex(this UIColor color, string hexValue, float alpha = 1.0f)
{
var colorString = hexValue.Replace("#", "");
if (alpha > 1.0f)
{
alpha = 1.0f;
}
else if (alpha < 0.0f)
{
alpha = 0.0f;
}
float red, green, blue;
switch (colorString.Length)
{
case 3: // #RGB
{
red = Convert.ToInt32(string.Format("{0}{0}",
colorString.Substring(0, 1)), 16) / 255f;
green = Convert.ToInt32(string.Format("{0}{0}",
colorString.Substring(1, 1)), 16) / 255f;
blue = Convert.ToInt32(string.Format("{0}{0}",
colorString.Substring(2, 1)), 16) / 255f;
return UIColor.FromRGBA(red, green, blue, alpha);
}
case 6: // #RRGGBB
{
red = Convert.ToInt32(colorString.Substring(0, 2), 16) / 255f;
green = Convert.ToInt32(colorString.Substring(2, 2), 16) / 255f;
blue = Convert.ToInt32(colorString.Substring(4, 2), 16) / 255f;
return UIColor.FromRGBA(red, green, blue, alpha);
}
default:
throw new ArgumentOutOfRangeException(string.Format("Invalid color value {0} is invalid. It should be a hex value of the form #RBG, #RRGGBB", hexValue));
}
}
}
当我们通过额外的函数扩展一个类时,第一个输入参数必须始终以 this 关键字开头;这代表调用该函数的当前对象。接下来的两个参数是一个表示十六进制值的字符串和一个透明度百分比(介于 0 和 1 之间)。
首先,我们从十六进制字符串中移除 # 字符。然后我们再次检查 alpha 字符是否小于 0,如果是,则将 alpha 设置为 0,反之亦然,如果 alpha 大于 1。然后我们的 switch 语句将根据十六进制字符串的长度(RGB 或 RRGGBB 值)选择一个案例。然后我们简单地提取红色、绿色和蓝色字符串值,并从红色、绿色和蓝色值返回一个新的 UIColor。
现在我们可以将十六进制颜色字符串应用到UIColor框架中,如下所示:
UIColor.Clear.FromHex("#FFFFFF");
小贴士
自从 MonoTouch 5.4 以来,我们必须将FromHex扩展应用到Color.Clear上。之前我们能够使用无参构造函数,如下所示:new UIColor().FromHex("FFFFFF")。
现在我们有了UIColor的添加,让我们将这些用于我们的 chatbox BackgroundColor属性。我们将在ChatView中添加一个新函数,该函数将创建一个新的ChatBox并根据是发送还是接收来设置颜色。我们还将对 x 轴位置做同样的处理,如果发送则将ChatBox设置在左侧,如果接收则设置在右侧:
public void CreateChatBox(bool received, string message)
{
_scrollView.ContentSize = new CGSize(_width, _currentTop);
_scrollView.AddSubview(new ChatBoxView(message)
{
Frame = new CGRect(received ? _width - 120 : 20, _currentTop, 100, 60),
BackgroundColor = UIColor.Clear.FromHex(received ? "#4CD964" : "#5AC8FA")
});
_currentTop += 80;
}
我们首先更新UIScrollView的ContentSize属性;这代表了滚动区域的尺寸。currentTop变量用于记录最后一个 ChatBox 的 y 轴值,这样我们就知道 UIScrollView 内容的长度,以及下一个 ChatBox 的下一个 y 轴位置。然后我们添加新的ChatBox对象,传入新的消息,并将消息分配给UILabel的Title。我们还使用我们新的扩展函数来设置ChatBox的BackgroundColor属性。
现在我们在哪里调用这个函数?
我们有两个区域,无论何时按下Send按钮,还是收到一条消息。让我们在_sendButton上添加TouchUpInside回调:
#region Private Properties private void HandleSendButton(object sender, EventArgs e)
{
_presenter.SendChat(_chatField.Text).ConfigureAwait(false);
CreateChatBox(false, _chatField.Text);
}
#endregion
HandleSendButton也会调用表示函数SendChat,并将消息发送到服务器Hub。我们还需要添加IChatView的实现。NotifyChatMessageReceived函数也会使用CreateChatBox,但这次我们将received标志设置为true。这必须在主线程上调用,因为有时事件可能会在另一个线程上触发此函数:
#region IChatView implementation
public void NotifyChatMessageReceived(string message)
{
InvokeOnMainThread(() => CreateChatBox(true, message));
}
#endregion
太棒了!
现在我们已经完成了 iOS ChatView,尝试测试。连接两个 iOS 客户端到Hub,从任一客户端选择另一个客户端,尝试在UITextField中输入消息,按下发送,看看魔法发生。
iOS 开发已经足够了,让我们回到 Android,完成ChatView。
Android TableLayouts
让我们回到 Android 实现。这部分很简单,我们已经将 UI 逻辑映射到ChatPresenter,所以让我们直接构建界面。对于我们的ChatView.xml文件,我们将引入一个TableLayout。TableLayouts 类似于Xamarin.Forms中的Grids;我们只是将一个区域分割成行和列。我们还可以将 UI 对象设置到特定的行和列,以及跨多行多列设置特定的 UI 对象。
让我们在Resources | layout文件夹中添加一个名为ChatView.xml的新文件,并实现以下内容:
<?xml version="1.0" encoding="utf-8"?>
<TableLayout
android:id="@+id/tableLayout"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:background="#FFFFFF">
<TableRow
android:id="@+id/tableRow1"
android:layout_width="fill_parent"
android:layout_height="100dp"
android:padding="5dip">
<EditText
android:id="@+id/chatField"
android:hint="Enter message"
android:textColor="#000000"
android:layout_weight="2"
android:layout_column="1" />
<Button
android:id="@+id/sendButton"
android:text="Send"
android:textColor="#417BB5"
android:background="@android:color/transparent"
android:focusableInTouchMode="false"
android:layout_weight="1"
android:layout_column="3" />
</TableRow>
<TableRow
android:id="@+id/tableRow2"
android:layout_width="fill_parent"
android:layout_weight="1"
android:padding="5dip">
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:layout_weight="2"
android:layout_span="4">
<LinearLayout
android:id="@+id/scrollViewInnerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</ScrollView>
</TableRow>
</TableLayout>
每一行都使用<TableRow>标签声明;我们的第一行包含一个用于消息的EditText项和一个按钮,用于在SignalRClient上调用SendChat函数。
构建 Android ChatActivity
让我们回到 Android 的实现。这部分很简单,我们已经将 UI 逻辑映射到了 ChatPresenter,所以让我们直接开始构建界面。在 Chat.Droid 项目的 Views 文件夹中添加一个新文件,命名为 ChatActivity.cs,并实现第一部分:
[Activity(Label = "Chat", ScreenOrientation = ScreenOrientation.Portrait)]
public class ChatView : ListActivity, ChatPresenter.IChatView
{
#region Private Properties
private ChatPresenter _presenter;
private LinearLayout _scrollViewInnerLayout;
private EditText _editText;
private long _lastSendClick = 0;
private int _width;
private float _currentTop;
private bool _dialogShown = false;
#endregion
#region Protected Methods
protected override void OnCreate(Bundle bundle)
{
base.OnCreate(bundle);
SetContentView(Resource.Layout.ChatView);
var metrics = Resources.DisplayMetrics;
_width = (int)(( metrics.WidthPixels) /
Resources.DisplayMetrics.Density);
_scrollViewInnerLayout = FindViewById<LinearLayout>
(Resource.Id.scrollViewInnerLayout);
_editText = FindViewById<EditText>(Resource.Id.chatField);
var sendButton = FindViewById<Button>(Resource.Id.sendButton);
sendButton.Touch += HandleSendButton;
var app = ChatApplication.GetApplication(this);
app.CurrentActivity = this;
_presenter = app.Presenter as ChatPresenter;
_presenter.SetView(this);
app.CurrentActivity = this;
}
#endregion
}
在 OnCreate 函数中,我们将内容视图设置为 ChatView 布局。然后我们获取屏幕宽度,因为我们需要能够将聊天框的 x 轴定位在屏幕的左侧或右侧,这取决于它是发送还是接收。然后我们将 SendButton 的 Touch 事件分配给调用 HandleSendButton 函数。最后,我们检索 ChatApplication 对象,并将表示器强制转换为 ChatPresenter,调用 SetView 函数,并传递 ChatActivity。然后我们将 ChatApplication 对象的 CurrentActivity 设置为 ChatActivity。我们还在 OnPause 上添加了一个覆盖,这样我们就可以在 ChatPresenter 上调用 ReleaseView 来从 SignalRClient 中移除 OnDataReceived 事件。这相当于在 UIViewController 上的 ViewDidUnload 覆盖:
protected override void OnPause()
{
base.OnPause();
if (_presenter != null)
{
_presenter.ReleaseView();
}
}
现在我们必须添加 IChatView 实现;CreateChatBox 必须传播到主线程,因为这个事件有时会在不同的线程上调用这个函数:
#region IChatView implementation
public void NotifyChatMessageReceived(string message)
{
// perform action on UI thread
Application.SynchronizationContext.Post(state =>
{
CreateChatBox(true, message);
}, null);
}
#endregion
现在我们必须添加 IView 实现,我们可以简单地从先前的活动复制:
#region IView implementation
public void SetErrorMessage(string message)
{
if (!_dialogShown)
{
_dialogShown = true;
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder
.SetTitle("Chat")
.SetMessage(message)
.SetNeutralButton("Ok", (sender, e) => { _dialogShown = false; })
.Show();
}
}
public bool IsInProgress { get; set; }
#endregion
在添加剩余的函数之前,我们将在 Android 中为 ChatBoxView 添加另一个布局。添加一个名为 ChatBoxView.xml 的新文件,将其添加到 资源 | 布局 文件夹中,并实现以下内容:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:weightSum="4">
<TextView
android:id="@+id/messageTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
这是一个非常简单的视图,它包含一个 LinearLayout,其中包含一个 TextView 来显示聊天消息。
最后,我们添加剩余的 HandleSendButton 和 CreateChatBox 函数;它们与 iOS 中的函数相同,但使用 Android 对象:
#region Private Methods
private void HandleSendButton(object sender, View.TouchEventArgs e)
{
// multiple-clicking prevention using a threshold of 1000 ms
if (SystemClock.ElapsedRealtime() - _lastSendClick < 1000)
{
return;
}
_lastSendClick = SystemClock.ElapsedRealtime();
_presenter.SendChat(_editText.Text).ConfigureAwait(false);
CreateChatBox(false, _editText.Text);
}
#endregion
#region Public Methods
public void CreateChatBox(bool received, string message)
{
var view = LayoutInflater.Inflate(Resource.Layout.ChatBoxView, null);
view.SetX(received ? _width : 0);
view.SetY(_currentTop);
var messageTextView = view.FindViewById<TextView>
(Resource.Id.messageTextView);
messageTextView.Text = message;
var color = Color.ParseColor(received ? "#4CD964" : "#5AC8FA");
messageTextView.SetBackgroundColor(color);
_scrollViewInnerLayout.AddView(view);
_currentTop += 60;
}
#endregion
HandleSendButton 函数将做完全相同的事情:调用表示器函数 SendChat,创建一个新的聊天框,并将其添加到 ScrollView。CreateChatBox 函数将使用上下文的 LayoutInflator 创建一个新的 ChatBoxView。然后我们将设置 x、y、宽度和高度属性,检索视图的 TextView 属性,并将 Text 属性设置为消息。然后我们在视图上调用 SetBackgroundColor 并根据是否已发送或接收更改背景颜色。最后,我们将新视图添加到 ScrollView 并记录当前的 y 轴值。
运行服务器和客户端
在我们能够一起测试所有内容之前,请重新查看第五章中的部分,名为本地托管 Web API 项目,即构建股票清单应用程序。这必须在我们可以从我们的移动客户端连接到服务器端之前完成。一旦我们有了运行的服务器应用程序,就可以从任一平台构建并运行移动应用程序,并在登录之前先注册一个用户。点击注册按钮将新账户放入UserManager中,允许我们使用这些账户详情(它们现在存在于UserManager中)进行登录。一旦我们登录,除非我们有一个可以运行应用程序并登录的另一个移动客户端,否则我们不能再做任何事情。最好使用运行移动应用程序的两个移动设备来测试这个应用程序。一旦两者都登录并且客户端列表屏幕已加载,每个用户都将连接到用户,现在两个用户都可以点击对方导航到聊天窗口并开始互相发送消息。
为了进一步了解正在发生的一切,尝试向服务器函数添加调试断点,并通过点击移动应用程序的不同屏幕来测试这些服务器函数。这将提供更好的概述,了解每个屏幕上服务器和客户端之间发生的事情。
摘要
在本章中,我们使用原生库为 iOS 和 Android 创建了一个应用程序。我们通过构建一个中心代理和通过客户端建立代理连接在客户端和服务器端集成了 SignalR。在下一章中,我们将看到如何使用依赖服务通过Xamarin.Forms在本地存储文件。你将了解共享项目和它们与 PCL 项目的不同之处。我们还将运行 SQLite,与 Android、iOS 和 WinPhone 一起设置它,并使用不同的平台特定库共享相同的代码。
第七章:构建文件存储应用程序
在本章中,我们将通过 Xamarin.Forms 的高级开发进行讲解。我们将探讨在 UI 元素上使用 Behaviors 的用法。然后我们将使用 Layout <View> 框架构建一个自定义布局。我们还将构建我们的第一个 SQLite 数据库来存储文本文件。本章将涵盖以下主题:
预期知识:
-
基础 Xamarin.Forms
-
XAML
-
MVVM
-
SQL
-
C# 线程
在本章中,你将学习以下内容:
-
项目结构设置
-
使用 SQLite 构建数据访问层
-
构建 ISQLiteStorage 接口
-
其他线程技术
-
创建 AsyncSemaphore
-
创建 AsyncLock
-
实现 SQLite 的本地设置要求
-
实现 IoC 容器和模块
-
实现跨平台日志记录
-
实现 SQLiteStorage 类
-
C# 6.0 语法简介
-
在视图模型中处理警报
-
构建 IMethods 接口
-
构建 ExtendedContentPage
-
使用自定义布局构建 CarouselView
-
向 CarouselView 添加滚动控制
-
构建用于原生手势的自定义渲染器
-
构建 UI
-
使用 SynchronizationContext
-
构建 EditFilePage
-
挑战
-
构建 Windows Phone 版本
项目结构设置
让我们从创建一个新的 Xamarin.Forms 项目开始。选择 文件 | 新建 | 解决方案,创建一个新的 Forms App,如图所示:**

将项目命名为FileStorage。一旦项目创建,创建另一个名为FileStorage.Portable的可移植类库,如图所示:

我们将从底层开始,逐步构建到原生项目。
使用 SQLite 构建数据访问层
在上一章中,我们关注了项目架构,并讨论了数据访问层这一层,这是我们的数据库层所在的位置。我们的数据访问层是我们将存储本地文本文件的地方。
SQLite 是移动设备上最常用的数据库框架。它是一个进程内库,实现了一个自包含、无服务器、零配置、事务性的 SQL 数据库引擎,并且免费使用。
注意
Xamarin 还支持其他框架,如 ADO.NET 和 Realm,但已经证明 SQLite 是最有效的数据库层。
设置过程的第一个步骤是在我们的 FileStorage.Portable 项目中添加以下 SQLite NuGet 包:
-
SQLite.Net.Async-PCL -
SQLite.Net.Core-PCL -
SQLite.Net-PCL
一旦你在你的包中添加了这些,它们应该看起来像以下这样:

下一个步骤是在此文件夹内创建一个名为 DataAccess 的新文件夹。在此文件夹内,创建两个名为 Storable 和 Storage 的子文件夹。在 Storable 文件夹内,添加一个名为 IStorable.cs 的新文件并实现以下内容:
public interface IStorable
{
string Key { get; set; }
}
这将是数据库中存储的每个对象类型的接口。在先前的例子中,我们只会有一个可存储的对象,并且每个可存储对象都必须有一个名为Key的字符串属性。这个属性将用作每个数据库表的主键。
在Storable文件夹中创建另一个名为FileStorable.cs的文件,并实现以下内容:
public class FileStorable : IStorable
{
#region Public Properties
[PrimaryKey] public string Key { get; set; }
public string Contents { get; set; }
#endregion
}
FileStorable对象将用作数据库中文件存储表的数据模型。在 SQLite 中,在数据库设置期间,表是通过以下方式从对象创建的:
CreateTable<FileStorable>(CancellationToken.None);
我们将FileStorable对象作为类型传递给CreateTable函数,用于映射表中的列。
构建 ISQLiteStorage 接口
现在我们必须设置另一个类,该类将用于控制对数据库执行的查询。在Storage文件夹中添加一个名为ISQLiteStorage.cs的新文件,并实现以下内容:
public interface ISQLiteStorage
{
void CreateSQLiteAsyncConnection();
Task CreateTable<T>(CancellationToken token) where T : class, IStorable, new();
Task InsertObject<T>(T item, CancellationToken token) where T : class, IStorable, new();
Task<IList<T>> GetTable<T>(CancellationToken token) where T : class, IStorable, new();
Task<T> GetObject<T>(string key, CancellationToken token) where T : class, IStorable, new();
Task ClearTable<T>(CancellationToken token) where T : class, IStorable, new();
Task DeleteObjectByKey<T>(string key, CancellationToken token) where T : class, IStorable, new();
void CloseConnection();
}
前面的接口定义了将在数据库上执行的所有函数。使用 SQLite 的优势在于它执行所有处理都是异步的,所以每个执行 SQL 查询的函数都返回一个任务。如果你仔细查看InsertObject和DeleteObjectByKey函数,你会发现它们需要一个类型,这意味着我们可以使用类型执行对特定表的查询。
添加额外的线程技术
这是我们将添加一些技巧的地方,使用一种常见的线程方法,称为异步锁定。由于将只有一个SQLiteStorage对象实例,这意味着我们有可能出现竞态条件,因为多个线程可以同时更改相同的数据库连接。
小贴士
竞态条件是常见的线程问题,其中多个线程试图同时在对共享数据进行操作。
我们如何解决这个问题?
锁定是 C#中最常见的用于在多个线程之间限制共享资源的做法。为了避免这种情况,我们创建一个对象用于锁定,如下所示:
private Object lockObject = new Object();
然后,为了限制代码块在任何时候只由一个线程访问,我们执行以下操作:
lock (thisLock)
{
...
}
当我们的代码是同步的时候,这是一个完美的方法。我们面临的问题是我们的 SQLite 实现是异步的,而基本锁定的限制是我们不能在锁语句中执行异步代码。这就是我们必须实现 async-lock 模式的地方。
创建 AsyncSemaphore
让我们在FileStorage.Portable项目中添加一个名为Threading的新文件夹。在这个文件夹中,我们将添加一个名为AsyncSemaphore.cs的新文件,并按照以下方式实现第一部分:
public class AsyncSemaphore
{
private readonly static Task s_completed = Task.FromResult(true);
private readonly Queue<TaskCompletionSource<bool>> m_waiters = new Queue<TaskCompletionSource<bool>>();
private int m_currentCount;
public AsyncSemaphore(int initialCount)
{
if (initialCount < 0) throw new ArgumentOutOfRangeException("initialCount");
m_currentCount = initialCount;
}
public Task WaitAsync()
{
lock (m_waiters)
{
if (m_currentCount > 0)
{
--m_currentCount;
return s_completed;
}
else
{
var waiter = new TaskCompletionSource<bool>();
m_waiters.Enqueue(waiter); return waiter.Task;
}
}
}
}
注意
SemaphoreSlim对象用于限制可以访问资源的线程数量。
AsyncSemaphore保持一个计数(m_count属性),这是它可用于满足等待者的开放槽位数量。
从WaitAsync函数(静态s_completed属性)返回的Task,当AsyncSemaphore给它提供一个可用槽位时,将进入完成状态。如果在等待得到满足之前CancellationToken被触发,则相同的Task将进入Canceled状态;在这种情况下,AsyncSemaphore不会丢失一个槽位。
注意
等待者只是一个布尔类型的TaskCompletionSource。它包含一个Task,这是单个线程要执行的操作。
创建 AsyncLock
现在我们已经构建了AsyncSemaphore类,我们将在AsyncLock对象中使用这个对象。让我们在Threading文件夹中添加一个名为AsyncLock.cs的新文件,并实现以下内容:
public class AsyncLock
{
private readonly AsyncSemaphore m_semaphore;
private readonly Task<Releaser> m_releaser;
public AsyncLock()
{
m_semaphore = new AsyncSemaphore(1);
m_releaser = Task.FromResult(new Releaser(this));
}
public Task<Releaser> LockAsync()
{
var wait = m_semaphore.WaitAsync();
return wait.IsCompleted ?
m_releaser :
wait.ContinueWith((_, state) =>
new Releaser((AsyncLock)state),
this, CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
public struct Releaser : IDisposable
{
private readonly AsyncLock m_toRelease;
internal Releaser(AsyncLock toRelease) { m_toRelease = toRelease; }
public void Dispose()
{
if (m_toRelease != null)
m_toRelease.m_semaphore.Release();
}
}
}
AsyncLock类使用AsyncSemaphore确保在任何时候只有一个线程可以访问LockAsync函数之后的代码块。可以通过调用LockAsync异步获取锁,并通过处理该任务的输出释放锁。AsyncLock接受一个可选的CancellationToken,可以用来取消获取锁。
从LockAsync函数返回的Task,在它获取了AsyncLock时将进入Completed状态。如果在等待得到满足之前CancellationToken被触发,则相同的Task将进入取消状态;在这种情况下,AsyncLock不会被该任务获取。
现在让我们回到实现SQLiteStorage类;这是我们将要实现异步锁模式的地方。
实现 SQLite 的本地设置要求
我们的下一步是添加最终的设置要求。每个设备平台在设置本地数据库连接时都必须使用特定的框架。这意味着我们将添加另一个依赖注入接口来设置这些本地要求。
在Storage文件夹中添加一个名为ISqliteSetup.cs的新文件,并实现以下内容:
public interface ISQLiteSetup
{
string DatabasePath { get; set; }
ISQLitePlatform Platform { get; set; }
}
在我们在平台项目中实现这个类之前,我们需要为所有平台项目添加以下 SQLite NuGet 包:
-
SQLite.Net.Async-PCL -
SQLite.Net.Core-PCL -
SQLite.Net-PCL
现在让我们将注意力转向 iOS 项目。添加一个名为DataAccess的新文件夹,添加一个名为SQLiteSetup.cs的新文件,并实现以下内容:
public class SQLiteSetup : ISQLiteSetup
{
public string DatabasePath { get; set; }
public ISQLitePlatform Platform { get; set; }
public SQLiteSetup(ISQLitePlatform platform)
{
DatabasePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "filestorage.db3");;
Platform = platform;
}
}
我们需要关注的主要属性是ISQLitePlatform。这来自SQLite.Net.Interop库。我们将在这个 IoC 容器中注册这个项目,因为我们将在便携式项目中创建数据库连接时需要这个实例。
在我们继续之前,我们需要使用 Autofac 设置 IoC 容器。
实现 IoC 容器和模块
就像我们之前的工程一样,我们将使用 Autofac 设置另一个 IoC 容器。首先,让我们将 Autofac nuget 包添加到解决方案中的所有项目中。然后,我们可以从第五章,构建股票列表应用程序中的Stocklist.Portable项目复制IoC文件夹。确保包括IoC.cs和IModule.cs文件。
现在,让我们跳转到原生项目,在 iOS 和 Android 项目中添加Modules文件夹,并实现IOSModule.cs和DroidModule.cs:
public class IOSModule : IModule
{
#region Public Methods
public void Register(ContainerBuilder builder)
{
builder.RegisterType<SQLiteSetup>().As<ISQLiteSetup>().SingleInstance();
builder.RegisterType<SQLitePlatformIOS>().As<ISQLitePlatform>().SingleInstance();
}
#endregion
}
以及 DroidModule,
public class DroidModule : IModule
{
#region Public Methods
public void Register(ContainerBuilder builder)
{
builder.RegisterType<SQLiteSetup>().As<ISQLiteSetup>().SingleInstance();
builder.RegisterType<SQLitePlatformAndroid>().As<ISQLitePlatform>().SingleInstance();
}
#endregion
}
注意
注意我们如何快速拼凑事物?
当你在构建跨平台应用程序的方向上正确时,多个平台支持带来的复杂性不应该是一个问题。
在上述两个模块内部,我们正在注册SQLiteSetup和SQLitePlatformIOS/Droid对象,以便SQLiteStorage实现可以在FileStorage.Portable项目中使用这些项。
在我们回到完成SQLiteStorage实现之前,我们将设置一个有用的日志方法,该方法可用于所有跨平台应用程序。
实现跨平台日志
现在我们有了我们的 IoC 容器,我们将使用依赖注入来进行日志记录。在跨平台应用程序中添加自定义日志功能对于跟踪所有不同项目之间的操作非常有用。第一步是添加一个名为Logging的新文件夹,添加一个名为ILogger.cs的新文件,并实现以下内容:
public interface ILogger
{
#region Methods
void WriteLine(string message);
void WriteLineTime(string message, params object[] args);
#endregion
}
对于这个例子,我们的日志记录器将使用 iOS 的System.Diagnostics中的标准Debug控制台,但在 Android 中我们将使用 Android 提供的广泛日志功能。
现在,让我们在 iOS 和 Android 中添加Logging文件夹,并实现以下内容:
public class LoggeriOS : ILogger
{
#region Public Methods
public void WriteLine(string text)
{
Debug.WriteLine(text);
}
public void WriteLineTime(string text, params object[] args)
{
Debug.WriteLine(DateTime.Now.Ticks + " " + String.Format(text, args));
}
#endregion
}
iOS 的日志记录并不太花哨,但我们有一个额外的输出行来记录带有当前时间的日志语句。
现在,对于 Android 实现,我们将使用Android.Util库的本地日志:
public class LoggerDroid : ILogger
{
#region Public Methods
public void WriteLine(string text)
{
Log.WriteLine(LogPriority.Info, text, null);
}
public void WriteLineTime(string text, params object[] args)
{
Log.WriteLine(LogPriority.Info, DateTime.Now.Ticks + " " +
String.Format(text, args), null);
}
#endregion
}
在Android.Util库的Log对象中,我们有指定优先级(info、debug、error)的选项。我们越能深入了解我们希望应用程序输出的具体内容,我们就能更好地跟踪底层发生的确切情况。
太棒了!现在让我们回到构建SQLiteStorage实现。
实现 SQLiteStorage 类
现在回到FileStorage.Portable项目。让我们在Storage文件夹中添加另一个名为SQLiteStorage.cs的文件,并实现private变量:
public class SQLiteStorage : ISQLiteStorage
{
#region Private Properties
private readonly AsyncLock asyncLock = new AsyncLock();
private readonly object lockObject = new object();
private SQLiteConnectionWithLock _conn;
private SQLiteAsyncConnection _dbAsyncConn;
private readonly ISQLitePlatform _sqlitePlatform;
private string _dbPath;
private readonly ILogger _log;
private readonly string _tag;
#endregion
}
我们有一个私有的 AsyncLock 对象,因为我们将会进行同步和异步锁定实现。然后我们有两个 SQLite 对象用于创建到本地数据库的连接。_dbPath 变量用于保存本地数据库路径;这将用于设置连接。我们还有我们的依赖服务接口 ILogger 和另一个用于标记当前对象的字符串。标记在日志记录中很有用,因为它告诉记录器哪个类正在记录。
C# 6.0 语法简介
现在让我们按照以下方式添加构造函数:
public SQLiteStorage(ISQLiteSetup sqliteSetup, ILogger log)
{
_dbPath = sqliteSetup?.DatabasePath;
_sqlitePlatform = sqliteSetup?.Platform;
_log = log; _tag = $"{GetType()} ";
}
在这里,我们可以看到一些 C# 6.0 的语法。在构造函数参数 sqliteSetup 后使用问号 (?) 表示,如果对象不为空,我们可以访问其属性。这避免了需要创建如下所示的 if 语句:
If (sqliteSetup != null)
_dbPath = sqliteSetup?.DatabasePath;
此外,还有一些 C# 6.0 的语法,如下所示:
_tag = $"{GetType()} ";
美元符号 ($) 用于插值字符串。插值字符串表达式通过将包含的表达式替换为表达式的 ToString 表示形式来创建字符串。
更仔细地看看我们正在分配的项目。我们正在使用 SQLiteSetup 对象来设置数据库路径和 SQLite 平台属性。
让我们添加我们的前两个方法:
public void CreateSQLiteAsyncConnection()
{
var connectionFactory = new Func<SQLiteConnectionWithLock>(() =>
{
if (_conn == null)
{
_conn = new SQLiteConnectionWithLock(_sqlitePlatform, new SQLiteConnectionString(_dbPath, true));
}
return _conn;
});
_dbAsyncConn = new SQLiteAsyncConnection(connectionFactory);
}
public async Task CreateTable<T>(CancellationToken token) where T : class, IStorable, new()
{
using (var releaser = await asyncLock.LockAsync())
{
await _dbAsyncConn.CreateTableAsync<T>(token);
}
}
CreateSQLiteAsyncConnection 函数创建一个新的 Func 类型为 SQLiteConnectionWithLock,我们使用这个 Func 来实例化一个新的 SQLiteAsyncConnection。Func 检查我们是否已经创建了数据库的连接。如果我们还没有建立这个连接,它将创建一个新的 SQLiteConnectionWithLock 对象,并传入从 SQLSetup 对象获取的数据库路径和平台。
在 CreateTable 函数中,我们将首次了解异步锁模式。AsyncLock 对象的伟大之处在于我们可以在 using 语句内包含 await。当一个线程在 SQLiteAsyncConnection 的一个实例上创建表时,另一个线程将不得不在 using 行处等待,直到前一个线程完成表的创建。
我们下一个函数是 GetTable。这个函数将再次使用异步锁模式,以确保在任何时候只有一个线程正在查询数据库。这个函数将执行一个标准的 SQL 查询,用于选择表的所有项:
SELECT * FROM {TableName};
表将由传递的类型 T 决定,从数据库接收到的结果将是所有表项的 IEnumerable<T> 类型:
public async Task<IList<T>> GetTable<T>(CancellationToken token) where T : class, IStorable, new()
{
var items = default(IList<T>);
using (var releaser = await asyncLock.LockAsync())
{
try
{
items = await _dbAsyncConn.QueryAsync<T>(string.Format("SELECT * FROM {0};", typeof(T).Name));
}
catch (Exception error)
{
var location = string.Format("GetTable<T>() Failed to 'SELECT *' from table {0}.", typeof(T).Name);
_log.WriteLineTime(_tag + "\n" + location + "\n" + "ErrorMessage: \n" + error.Message + "\n" + "Stacktrace: \n " + error.StackTrace);
}
}
return items;
}
注意我们是如何捕获在此查询中可能发生的任何异常的?
我们正在构建一个位置字符串,以确定异常发生的确切位置。然后我们使用我们的 ILogger 实现将自定义的异常字符串路由到特定的原生输出控制台。
接下来是InsertObject函数。这个函数将负责将新条目添加到数据库的正确表中。我们还将使用 async-lock 模式来锁定连接,以防止在插入操作进行时访问连接:
public async Task InsertObject<T>(T item, CancellationToken token) where T : class, IStorable, new()
{
using (var releaser = await asyncLock.LockAsync())
{
try
{
var insertOrReplaceQuery = item.CreateInsertOrReplaceQuery();
await _dbAsyncConn.QueryAsync<T>(insertOrReplaceQuery);
}
catch (Exception error)
{
var location = string.Format("InsertObject<T>() Failed to insert
or replace object with key {0}.", item.Key);
_log.WriteLineTime(_tag + "\n" + location + "\n" + "ErrorMessage:
\n" + error.Message + "\n" + "Stacktrace: \n " +
error.StackTrace);
}
}
}
注意到CreateInsertOrReplaceQuery函数吗?
我们将向IStorable接口添加一个扩展类。在FileStorage.Portable项目中,在DataAccess| Storable位置添加一个名为StorableExtensions.cs的新文件,并实现以下内容:
public static class StorableExtensions
{
#region Public Methods
public static string CreateInsertOrReplaceQuery(this IStorable storable)
{
var properties = storable.GetType().GetRuntimeProperties();
var tableName = storable.GetType().Name;
string propertiesString = "";
string propertyValuesString = "";
var index = 0;
foreach (var property in properties)
{
propertiesString += (index == (properties.Count() - 1)) ?
property.Name : property.Name + ", ";
var value = property.GetValue(storable);
var valueString = value == null ? "null" : value is bool ? "'"
+ ((bool)value ? 1 : 0) + "'" : "'" + value + "'";
// if data is serialized if (property.Name.Equals("Data") &&
!valueString.Equals("null"))
{
valueString = valueString.Replace(""", """);
}
propertyValuesString += valueString +
((index == (properties.Count() - 1)) ? string.Empty : ", ");
index++;
}
return string.Format("INSERT OR REPLACE INTO {0}({1})
VALUES ({2});", tableName, propetiesString, propertyValuesString);
}
#endregion
}
前面的函数足够聪明,可以从继承自IStorable接口的任何条目中构建插入和替换查询。它使用System.Reflection库通过GetRuntimeProperties函数检索IStorable对象的全部属性。然后我们遍历所有属性,根据以下语法构建查询:
INSERT OR REPLACE INTO names (prop1, prop2, ...) VALUES (val1, val2, ...)
小贴士
如果我们没有在FileStorable类的Key属性上设置PrimaryKey属性,更新将不会工作,并且每次都会添加一个新的条目。
现在是DeleteObjectByKey函数。这个函数将用于通过IStorable接口的Key属性从表中删除一个条目:
public async Task DeleteObjectByKey<T>(string key, CancellationToken token) where T : class, IStorable, new()
{
using (var releaser = await asyncLock.LockAsync())
{
try
{
await _dbAsyncConn.QueryAsync<T>(string.Format("DELETE FROM {0} WHERE Key='{1}';", typeof(T).Name, key));
}
catch (Exception error)
{
var location = string.Format("DeleteObjectByKey<T>() Failed to
delete object from key {0}.", key);
_log.WriteLineTime(_tag + "\n" + location + "\n" +
"ErrorMessage: \n" + error.Message + "\n" + "Stacktrace: \n " +
error.StackTrace);
}
}
}
太棒了!SQLite 已经设置并集成了 async-lock 模式以使其线程安全。我们的最后一步是为 IoC 容器添加PortableModule并注册SqliteStorage类。
在FireStorable.Portable项目中,创建一个名为Modules的新文件夹,添加一个名为PortableModule.cs的新文件,并实现以下内容:
public class PortableModule : IModule
{
#region Public Methods
public void Register(ContainerBuilder builder)
{
builder.RegisterType<SQLiteStorage>().As<ISQLiteStorage>().SingleInstance();
}
#endregion
}
现在我们可以开始构建用户界面层,并开始构建一些自定义 UI 对象。
在视图模型中处理警报
通过视图模型处理警报很重要,因为我们通过try/catch语句处理许多错误。为了响应这些错误,我们希望显示一个警报对话框,向用户显示错误消息。我们将有两种方法来完成这项工作:
-
使用
EventHandler将事件推送到当前页面,以便我们可以使用不同的消息调用DisplayAlert函数 -
使用接口进行依赖注入,我们将实现原生警报
我们的第一步是添加ViewModelBase类;这是触发警报的地方。
在FileStorage.Portable项目中创建一个名为ViewModels的新文件夹,添加一个名为ViewModelBase.cs的新文件,并实现以下内容:
public class ViewModelBase : INotifyPropertyChanged
{
#region Public Events
public event PropertyChangedEventHandler PropertyChanged;
public event EventHandler<string> Alert;
#endregion
#region Private Properties
private IMethods _methods;
#endregion
#region Public Properties
public INavigationService Navigation;
#endregion
#region Constructors
public ViewModelBase(INavigationService navigation, IMethods methods)
{
Navigation = navigation;
_methods = methods;
}
#endregion
}
我们使用与第五章中相同的ViewModelBase实现,即构建股票列表应用程序,但我们将在构造函数中添加一个额外的IMethods接口(我们稍后将实现它),该接口用于显示原生警报。
接下来,添加受保护的OnPropertyChanged和LoadAsync方法,如下所示:
#region Protected Methods
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
protected virtual async Task LoadAsync(IDictionary<string, object> parameters)
{
}
#endregion
以及以下公共方法:
#region Public Methods
public Task<string> ShowEntryAlert(string message)
{
var tcs = new TaskCompletionSource<string>();
_methods.DisplayEntryAlert(tcs, message);
return tcs.Task;
}
public void NotifyAlert(string message)
{
if (Alert != null)
{
Alert(this, message);
}
}
public void OnShow(IDictionary<string, object> parameters)
{
LoadAsync(parameters).ToObservable().Subscribe( result =>
{
// we can add things to do after we load the view model }, ex =>
{
// we can handle any areas from the load async function });
}
#endregion
注意
尽管我们正在便携式项目中工作,但这仍然是架构中的表示层的一部分。
NotifyAlert函数用于通过Xamarin.Forms函数DisplayAlert在ContentPage上显示警报。ShowEntryAlert函数用于通过IMethod接口显示警报。
注意到TaskCompletionSource的使用吗?
这意味着我们可以等待ShowEntryAlert函数。当用户对警报做出响应时,Task将进入完成状态。这确保了代码仅在收到响应后执行一次。
构建 IMethods 接口
首先,在FileStorage.Portable项目中创建一个新的文件夹,添加一个名为IMethods.cs的新文件,并实现以下内容:
public interface IMethods
{
#region Methods
void Exit();
void DisplayEntryAlert(TaskCompletionSource<string> tcs, string message);
#endregion
}
对于所有原生项目,添加一个名为Extras的新文件夹。让我们从 iOS 项目开始。添加一个名为IOSMethods.cs的新文件,并实现以下内容:
public class IOSMethods : IMethods
{
#region Public Methods
public void Exit()
{
UIApplication.SharedApplication.PerformSelector(new ObjCRuntime.Selector("terminateWithSuccess"), null, 0f);
}
public void DisplayEntryAlert(TaskCompletionSource<string> tcs, string message)
{
UIAlertView alert = new UIAlertView(); alert.Title = "Title";
alert.AddButton("OK");
alert.AddButton("Cancel");
alert.Message = message;
alert.AlertViewStyle = UIAlertViewStyle.PlainTextInput;
alert.Clicked += (object s, UIButtonEventArgs ev) =>
{
if (ev.ButtonIndex == 0)
{
tcs.SetResult(alert.GetTextField(0).Text);
}
else
{
tcs.SetResult(null);
}
};
alert.Show();
}
#endregion
}
我们应该识别前几章中的Exit函数。DisplayEntryAlert函数创建一个PlainTextInputUIAlertView。此警报将通过文本框请求文本输入,我们可以使用GetTextField函数检索此文本值。警报还将显示一个是和否按钮,因此当用户输入文本并点击是时,将创建一个新文件,其文件名设置为输入的文本。
现在让我们为 Android 复制相同的程序。添加一个名为DroidMethods.cs的新文件,并实现以下内容:
public class DroidMethods : IMethods
{
#region Public Methods
public void Exit()
{
Android.OS.Process.KillProcess(Android.OS.Process.MyPid());
}
public void DisplayEntryAlert(TaskCompletionSource<string> tcs, string message)
{
var context = Forms.Context;
LayoutInflater factory = LayoutInflater.From(context);
var view = factory.Inflate(Resource.Layout.EntryAlertView, null);
var editText = view.FindViewById<EditText>(Resource.Id.textEntry);
new AlertDialog.Builder(context)
.SetTitle("Chat")
.SetMessage(message)
.SetPositiveButton("Ok", (sender, e) =>
{
tcs.SetResult(editText.Text);
})
.SetNegativeButton("Cancel", (sender, e) =>
{
tcs.SetResult(null);
})
.SetView(view)
.Show();
}
#endregion
}
这次为 Android,我们使用AlertDialog.Builder框架。我们使用Forms.Context属性来检索当前上下文,我们使用它来创建一个新的AlertDialog.Builder。我们必须在这个框架中使用SetView函数来分配一个自定义视图用于文本输入。这个自定义视图是使用一个新的 XML 布局创建的。
在资源 | 布局文件夹中添加一个名为EntryAlertView.xml的新文件,并实现以下内容:
<?xml version="1.0" encoding="utf-8"?>
<EditText
android:id="@+id/textEntry" android:layout_height="wrap_content"
android:layout_width="250px" android:layout_centerHorizontal="true"
android:singleLine="true" />
我们所拥有的只是一个EditText对象,用于在警报对话框中从用户那里检索文件名。在DroidMethods类中使用FindViewById,我们可以引用此EditText项以检索用户输入的文本值。
那就是全部了。我们的下一步是创建一个定制的ContentPage来处理每个视图模型的Alert事件。
构建扩展内容页面
在FileStorage项目中添加一个名为UI的新文件夹,添加一个名为ExtendedContentPage.cs的新文件,并实现以下内容:
public class ExtendedContentPage : ContentPage
{
#region Private Properties
private ViewModelBase _model;
#endregion
#region Constructors
public ExtendedContentPage(ViewModelBase model)
{
_model = model;
_model.Alert -= HandleAlert;
_model.Alert += HandleAlert;
}
#endregion
#region Private Methods
private async void HandleAlert(object sender, string message)
{
await DisplayAlert("FileStorage", message, "OK");
}
#endregion
}
_model属性用于引用每个页面的视图模型,因为每个视图模型都继承自ViewModelBase类。当页面创建时,我们将HandleAlert函数注册到视图模型的Alert事件。每次此函数被调用时,它将调用Xamarin.Forms中的DisplayAlert函数。
为什么我们要实现两种不同的显示警报的技术?
显示警报的跨平台功能不允许我们使用我们本地构建的文本输入添加功能。
太好了!我们现在为我们的跨平台项目找到了一个很好的多种类型警报的解决方案。我们的下一步是实现我们第一个自定义布局,称为 CarouselView。
提示
Xamarin.Forms 有自己的 CarouselView,但它已被移除,直到 UI 对象更加稳定。
使用自定义布局构建 CarouselView
Xamarin.Forms 是一个非常年轻的布局系统,这意味着布局的种类相当有限。有时候,我们需要实现自己的自定义布局来控制我们的视图和控制元素在屏幕上的确切位置和方式。这种需求可能来自于需要提高显示大量视图和控制元素的屏幕性能的情况,有时标准布局不足以满足需求。我们希望实现自定义布局以执行产生所需布局所需的最小工作量。
注意
所有布局都派生自 Xamarin.Forms.Layout 类,它提供了添加和移除子项所需的机制,以及一些编写布局时的一些关键实用工具。
让我们在 FireStorable 项目中添加一个名为 Controls 的新文件夹。添加一个名为 CarouselLayout.cs 的新文件,并按照以下方式实现第一部分:
public class CarouselLayout : Layout<View>
{
#region Private Properties
private IDisposable dataChangesSubscription;
public double LayoutWidth;
#endregion
}
所有布局都必须继承 Layout 框架。Xamarin.Forms.Layout<T> 提供了一个公开的 IList<T> Children,最终用户可以访问。我们希望这个集合的所有子项都是 View 类型。
我们有两个 private 属性,一个用于布局宽度,一个 IDisposable 用于处理数据更改订阅。
让我们添加一些更多属性:
#region Public Properties
public Object this[int index]
{
get
{
return index < ItemsSource.Count() ? ItemsSource.ToList()[index] : null;
}
}
public DataTemplate ItemTemplate { get; set; }
public IEnumerable<Object> ItemsSource { get; set; }
#endregion
我们有一个索引引用,它将从 ItemsSource IEnumerable 返回一个数组元素,以及 ItemTemplate 属性,它用于为 ItemsSource 中的每个子项渲染视图布局。我们必须使用 Linq 函数 ToList 来允许我们通过索引值访问 IEnumerable。
现在我们将添加一些对 Layout 框架的重写。每个自定义布局都必须重写 LayoutChildren 方法。这负责在屏幕上定位子项:
protected override void LayoutChildren(double x, double y, double width, double height)
{
var layout = ComputeLayout(width, height);
var i = 0;
foreach (var region in layout)
{
var child = Children[i];
i++;
LayoutChildIntoBoundingRegion(child, region);
}
}
前面的函数将调用另一个方法 ComputeLayout,该方法将返回一个 Rectangles(也称为 regions)的 IEnumerable。然后我们遍历 IEnumerable 并为每个区域调用 LayoutChildIntoBoundingRegion。此方法将处理元素相对于边界区域的位置。
我们的布局还必须实现 OnMeasure 函数。这是为了确保新布局在放置在其他布局内部时尺寸正确。在布局周期中,这个方法可能会根据其上方的布局和解决当前布局层次结构所需的布局异常数量多次被调用。在 LayoutChildren 函数下方添加以下内容:
protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
{
List<Row> layout = ComputeNiaveLayout(widthConstraint, heightConstraint);
var last = layout[layout.Count - 1];
var width = (last.Count > 0) ?
last[0].X + last.Width : 0; var height = (last.Count > 0) ? last[0].Y +
last.Height : 0;
return new SizeRequest(new Size(width, height));
}
提示
因此,在实现此函数时考虑速度很重要。未能实现此函数并不总是会破坏你的布局,尤其是如果它始终在父元素内部,因为父元素无论如何都会固定子项的大小。
ComputeNiaveLayout 将返回一个 行 列表。然后我们从该列表中检索最后一行,并使用它来确定最大 x 值和最大 y 值,通过计算 x 轴和 y 轴上第一个和最后一个元素之间的差异来计算总宽度和高度。最后,我们返回一个新的 SizeRequest 对象,包含计算出的宽度和高度,这将用于调整布局的大小。
让我们添加缺失的函数 ComputeNiaveLayout 和 ComputeLayout,如下所示:
public IEnumerable<Rectangle> ComputeLayout(double widthConstraint, double heightConstraint)
{
List<Row> layout = ComputeNiaveLayout(widthConstraint, heightConstraint);
return layout.SelectMany(s => s);
}
此函数仅用于执行 SelectMany 查询。ComputeNiaveLayout 布局是所有工作完成的地方。这将遍历所有子项;它将创建一个 行,并在该行内创建一个矩形,该矩形的大小将等于布局的高度,宽度将等于所有子项宽度的总和。所有子项将水平并排放置在屏幕右侧,如下面的截图所示:

但在任何时候屏幕上只能看到一个子项,因为每个子项的大小被设置为布局的完整高度和宽度:
private List<Row> ComputeNiaveLayout(double widthConstraint, double heightConstraint)
{
var result = new List<Row>();
var row = new Row();
result.Add(row);
var spacing = 20;
double y = 0;
foreach (var child in Children)
{
var request = child.Measure(double.PositiveInfinity,
double.PositiveInfinity);
if (row.Count == 0)
{
row.Add(new Rectangle(0, y, LayoutWidth, Height));
row.Height = request.Request.Height; continue;
}
var last = row[row.Count - 1];
var x = last.Right + spacing;
var childWidth = LayoutWidth;
var childHeight = request.Request.Height;
row.Add(new Rectangle(x, y, childWidth, Height));
row.Width = x + childWidth; row.Height = Math.Max(row.Height, Height);
}
return result;
}
等一下!如果我有很多子项呢?这意味着它们将水平堆叠超过屏幕宽度。我们现在该怎么办?
CarouselView 的理念是每次只显示一个视图,当用户左右滑动时;当前视图左侧/右侧的视图将出现在屏幕上,而当前视图将移出视图,如下面的截图所示:

尽管我们有一个水平展示子项的自定义布局,但我们如何处理滑动事件和滚动控制?
我们将通过 ScrollView 实现滚动控制,并为处理滑动事件创建一个自定义渲染器。
向 CarouselView 添加滚动控制
在 Controls 文件夹中添加一个名为 CarouselScroll.cs 的新文件,并按照以下方式实现第一部分:
public class CarouselScroll : ScrollView
{
#region Private Properties
private CarouselLayout _carouselLayout;
#endregion
public DataTemplate ItemTemplate
{
set
{
_carouselLayout.ItemTemplate = value;
}
}
public CarouselScroll()
{
Orientation = ScrollOrientation.Horizontal;
_carouselLayout = new CarouselLayout();
Content = _carouselLayout;
}
}
CarouselScroll 将继承 ScrollView 对象,因为这将作为 CarouselLayout 的边界视图。我们还将创建一个 DataTemplate 变量,用于在 CarouselLayout 中设置 DataTemplate 对象。然后,在构造函数中,我们实例化一个新的 CarouselLayout 对象,作为 ScrollView 的 Content。
现在让我们为 ItemsSource 添加一个自定义绑定对象。就像 ListView 一样,我们将绑定一个 ObserableCollection 项到这个属性:
public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create<CarouselLayout, IEnumerable<Object>>(o => o.ItemsSource,
default(IEnumerable<Object>), propertyChanged: (bindable, oldvalues, newValues) =>
{
((CarouselScroll)bindable)._carouselLayout.ItemsSource = newValues;
});
注意 propertyChanged 事件;当绑定发生变化时,我们将更新 CarouselLayout 的 ItemsSource 属性。记住,CarouselLayout 负责为 IEnumerable 中的每个项目布局子项。
我们还需要另一个可绑定属性来处理数据变化。这将是一个IObservable对象,它将监听任何DataChange事件。如果发生事件,CarouselLayout将布局子项:
public static readonly BindableProperty DataChangesProperty = BindableProperty.Create("DataChanges",
typeof(IObservable<DataChange>), typeof(CarouselLayout), null, propertyChanged: (bindable, oldvalue, newValue) =>
{
((CarouselScroll)bindable)._carouselLayout.SubscribeDataChanges((IObservable<DataChange>)newValue);
});
然后我们需要重写LayoutChildren函数;因此当ScrollView更新其子项时,我们希望更新CarouselLayout的高度和宽度属性,从而更新子项的布局:
protected override void LayoutChildren(double x, double y, double width, double height)
{
base.LayoutChildren(x, y, width, height);
if (_carouselLayout != null)
{
if (width > _carouselLayout.LayoutWidth)
{
_carouselLayout.LayoutWidth = width;
}
_carouselLayout.ComputeLayout(width, height);
}
}
我们还有一个额外的函数,GetSelectedItem,它简单地使用索引从CarouselLayout返回一个子项:
public Object GetSelectedItem(int selected)
{
return _carouselLayout[selected];
}
我们进入CarouselView的下一阶段是创建一个允许滑动手势的CustomRenderer。
构建用于原生手势的 CustomRenderer
现在我们需要为每个移动平台处理左右滑动手势。不幸的是,Xamarin.Forms没有提供跨平台的滑动手势功能,因此我们需要自己实现这个功能。为了做到这一点,我们将构建一个CustomRenderer。首先,在Controls文件夹中添加一个名为GestureView.cs的新文件并实现以下内容:
public class GestureView : View
{
public event EventHandler SwipeLeft;
public event EventHandler SwipeRight;
public event EventHandler Touch;
public void NotifySwipeLeft()
{
if (SwipeLeft != null)
{
SwipeLeft (this, EventArgs.Empty);
}
}
public void NotifySwipeRight()
{
if (SwipeRight != null)
{
SwipeRight (this, EventArgs.Empty);
}
}
public void NotifyTouch()
{
if (Touch != null)
{
Touch(this, EventArgs.Empty);
}
}
}
此视图为每个手势都有一个EventHandler,我们还需要一个用于点击事件的手势。尽管Xamarin.Forms在运行时渲染在CarouselView之上时提供了这个功能,但Xamarin.Forms的手势将不再工作。
现在,在FileStorage.iOS项目中,让我们添加一个名为Renderers的新文件夹,并在其中添加一个名为GestureView的子文件夹。然后,在GestureView文件夹中,添加一个名为GestureViewiOS.cs的新文件并实现以下内容:
[Register("GestureViewiOS")]
public sealed class GestureViewiOS : UIView
{
private UIView _mainView;
private UISwipeGestureRecognizer _swipeLeftGestureRecognizer;
private UISwipeGestureRecognizer _swipeRightGestureRecognizer;
private UITapGestureRecognizer _tapGestureRecognizer;
public GestureViewiOS()
{
_mainView = new UIView ()
{
TranslatesAutoresizingMaskIntoConstraints = false
};
_mainView.BackgroundColor = UIColor.Clear;
Add (_mainView);
// set layout constraints for main view AddConstraints
(NSLayoutConstraint.FromVisualFormat("V:|[mainView]|",
NSLayoutFormatOptions.DirectionLeftToRight, null,
new NSDictionary("mainView", _mainView)));
AddConstraints (NSLayoutConstraint.FromVisualFormat("H:|[mainView]|", NSLayoutFormatOptions.AlignAllTop, null, new NSDictionary ("mainView", _mainView)));
}
}
此视图为每个手势都有一个EventHandler,我们还需要一个用于点击事件的手势。尽管Xamarin.Forms在运行时渲染在CarouselView之上时提供了这些功能,但Xamarin.Forms的手势将不再工作。
public void InitGestures(GestureView swipeView)
{
_swipeLeftGestureRecognizer = new UISwipeGestureRecognizer (swipeView.NotifySwipeLeft);
_swipeLeftGestureRecognizer.Direction = UISwipeGestureRecognizerDirection.Left;
_swipeRightGestureRecognizer = new UISwipeGestureRecognizer (swipeView.NotifySwipeRight);
_swipeRightGestureRecognizer.Direction = UISwipeGestureRecognizerDirection.Right;
_tapGestureRecognizer = new UITapGestureRecognizer(swipeView.NotifyTouch);
_tapGestureRecognizer.NumberOfTapsRequired = 1;
_mainView.AddGestureRecognizer (_swipeLeftGestureRecognizer);
_mainView.AddGestureRecognizer (_swipeRightGestureRecognizer);
_mainView.AddGestureRecognizer (_tapGestureRecognizer);
}
此函数将只从GestureViewRenderer的OnElementChanged函数中调用一次。
现在让我们添加渲染器类。添加一个名为GestureViewRenderer.cs的新文件并实现以下内容:
public class GestureLayoutRenderer : ViewRenderer<GestureView, GestureViewiOS>
{
private GestureViewiOS _swipeViewIOS;
private bool gesturesAdded;
public GestureLayoutRenderer()
{
_swipeViewIOS = new GestureViewiOS ();
}
protected override void OnElementChanged (ElementChangedEventArgs<GestureView> e)
{
base.OnElementChanged (e);
if (Control == null)
{
SetNativeControl(_swipeViewIOS);
}
if (Element != null && !gesturesAdded)
{
_swipeViewIOS.InitGestures(Element);
gesturesAdded = true;
}
}
}
每当UI对象的属性发生变化时,OnElementChanged函数将被调用。如果渲染器的Control属性为 null,我们只调用一次SetNativeControl。渲染器的Element属性通常是Xamarin.Forms项目中的 UI 对象(在我们的案例中是FileStorage项目的GestureView)。当我们收到GestureView对象的引用(在OnElementChanged函数内部)时,我们将此传递给InitGestures函数,以便在GestureView对象上使用EventHandlers。现在,当我们左右滑动或点击原生mainView对象时,它将调用GestureView对象的NotifySwipeLeft、NotifySwipeRight和NotifyTouch函数。
不要忘记在命名空间声明上方添加以下行:
[assembly: Xamarin.Forms.ExportRenderer(typeof(FileStorage.Controls.GestureView), typeof(FileStorage.iOS.Renderers.GestureView.GestureLayoutRenderer))]
namespace FileStorage.iOS.Renderers.GestureView
我们必须始终向自定义渲染器类添加ExportRenderer属性,以指定它将用于渲染Xamarin.Forms控件。
GestureViewiOS 对象将是我们 FileStorage 项目中显示在 GestureView 对象之上的视图。无论在 ContentPage 中放置新的 GestureView 对象的位置如何,GestureViewRenderer 都将在其位置渲染一个新的 GestreViewiOS 视图。
现在让我们为 Android 实现相同的操作。在 FileStorage.Droid 项目中添加一个名为 Renderers 的新文件夹,并在其中添加一个名为 GestureView 的新文件夹。然后,在 GestureView 文件夹中,添加一个名为 GestureListener.cs 的新文件并实现第一部分:
public class GestureListener : GestureDetector.SimpleOnGestureListener
{
private const int SWIPE_THRESHOLD = 50;
private const int SWIPE_VELOCITY_THRESHOLD = 50;
private GestureView _swipeView;
public void InitCoreSwipeView(GestureView swipeView)
{
_swipeView = swipeView;
}
}
GestureDetector 用于响应特定视图的多种类型的按下事件。我们还把 Xamarin.Forms GestureView 对象传递到这个类中,这样我们就可以在特定事件发生时触发 NotifySwipeLeft、NotifySwipeRight 和 NotifyTouch 函数。阈值值用作最小滑动距离和触摸压力。当用户在这个视图中进行滑动时,必须施加一定量的压力和移动才能触发事件。
注意
SimpleOnGestureListener 扩展是一个便利类,当你只想监听所有手势的子集时使用。
现在我们必须重写以下函数(我们不会对这些函数做任何事情,但它们必须被重写):
public override void OnLongPress (MotionEvent e)
{
base.OnLongPress (e);
}
public override bool OnDoubleTap (MotionEvent e)
{
return base.OnDoubleTap (e);
}
public override bool OnDoubleTapEvent (MotionEvent e)
{
return base.OnDoubleTapEvent (e);
}
public override bool OnDown (MotionEvent e)
{
return base.OnDown (e);
}
public override bool OnScroll (MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)
{
return base.OnScroll (e1, e2, distanceX, distanceY);
}
public override void OnShowPress (MotionEvent e)
{
base.OnShowPress (e);
}
public override bool OnSingleTapConfirmed (MotionEvent e)
{
return base.OnSingleTapConfirmed (e);
}
现在是使用函数的时候了。OnSingleTapUp 函数将负责处理触摸事件,当用户对视图应用单次点击手势时调用:
public override bool OnSingleTapUp (MotionEvent e)
{
_swipeView.NotifyTouch();
return base.OnSingleTapUp (e);
}
OnFling 函数负责处理滑动事件。两个 MotionEvent 项目是用户开始滑动和手指移除时的起始点和结束点 (x, y)。我们计算拖动距离,并确保 diffX 的绝对值大于 diffY 的绝对值。这确保了我们是在水平拖动。然后我们确保 diffX 的绝对值大于 Swipe_Threshold,并且 VelocityX 大于 Swipe_Velocity_Threshold。如果所有这些条件都满足,那么当 diffX 为正时,我们将触发向右滑动;否则,将触发向左滑动:
public override bool OnFling (MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)
{
try
{
float diffY = e2.GetY() - e1.GetY();
float diffX = e2.GetX() - e1.GetX();
if (Math.Abs(diffX) > Math.Abs(diffY))
{
if (Math.Abs(diffX) > SWIPE_THRESHOLD && Math.Abs(velocityX) > SWIPE_VELOCITY_THRESHOLD)
{
if (_swipeView != null)
{
if (diffX > 0)
{
_swipeView.NotifySwipeRight ();
}
else
{
_swipeView.NotifySwipeLeft ();
}
}
}
}
}
catch (Exception) { }
return base.OnFling (e1, e2, velocityX, velocityY);
}
现在我们来构建 GestureViewRenderer 并将其与 GestureDetector 集成。在 Gesture 文件夹中添加一个名为 GestureViewRenderer.cs 的新文件并实现以下内容:
public class GestureViewRenderer : ViewRenderer<GestureView, LinearLayout>
{
private LinearLayout _layout;
private readonly GestureListener _listener;
private readonly GestureDetector _detector;
public GestureViewRenderer ()
{
_listener = new GestureListener ();
_detector = new GestureDetector (_listener);
_layout = new LinearLayout (Context);
}
}
我们现在将创建一个空的 LinearLayout 用于 Control。这是将接收触摸事件的空白视图。然后我们从这个上面实例化一个新的 GestureListener 并将其传递给一个新的 GestureDetector。GestureDetector 的 OnTouchEvent 函数被用于所有触摸和运动事件,在这个类中我们更详细地分解事件以确定确切发生的事件:
protected override void OnElementChanged (ElementChangedEventArgs<GestureView> e)
{
base.OnElementChanged (e);
if (e.NewElement == null)
{
GenericMotion -= HandleGenericMotion;
Touch -= HandleTouch;
}
if (e.OldElement == null)
{
GenericMotion += HandleGenericMotion;
Touch += HandleTouch;
}
if (Element != null)
{
_listener.InitCoreSwipeView(Element);
}
SetNativeControl (_layout);
}
private void HandleTouch (object sender, TouchEventArgs e)
{
_detector.OnTouchEvent (e.Event);
}
private void HandleGenericMotion (object sender, GenericMotionEventArgs e)
{
_detector.OnTouchEvent (e.Event);
}
注意到对参数的 OldElemenet 和 NewElement 属性的空值检查吗?
如果 OldElemenet 为空,我们必须注销触摸事件,如果 NewElement 为空,则注册 GenericMotion 和 Touch 事件。
现在我们已经准备好了GestureView和GestureViewRenderers,是时候创建最终控件并添加一个名为CarouselView.xaml的新Forms ContentView Xaml文件,如下面的截图所示:

我们还在CarouselView.xaml中实现了以下内容:
<?xml version="1.0" encoding="UTF-8"?>
<ContentView
x:Class="FileStorage.Controls.CarouselView">
<ContentView.Content>
<Grid x:Name="Container">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<controls:CarouselScroll x:Name="CarouselScroll" ItemsSource="{Binding Cells}"
ItemTemplate="{StaticResource CarouselTemplate}"
DataChanges="{Binding DataChanges}" Grid.Row="0" Grid.Column="0"/>
<controls:GestureView x:Name="GestureView" Grid.Row="0" Grid.Column="0"/>
</Grid>
</ContentView.Content>
</ContentView>
上述代码将创建一个Grid,将其覆盖在GestureView之上,位于CarouselScroll之上。这意味着GestureView将检测滑动和触摸事件,并将这些事件传递给CarouselScroll。
现在让我们按照以下内容实现CarouselView.xaml.cs:
public partial class CarouselView : ContentView
{
private bool _animating;
public int SelectedIndex = 0;
public static readonly BindableProperty SelectedCommandProperty = BindableProperty.Create<CarouselView, ICommand>(w => w.SelectedCommand, default(ICommand),
propertyChanged: (bindable, oldvalue, newvalue) => { });
public ICommand SelectedCommand
{
get
{
return (ICommand)GetValue(SelectedCommandProperty);
}
set
{
SetValue(SelectedCommandProperty, value);
}
}
public CarouselView()
{
InitializeComponent();
GestureView.SwipeLeft += HandleSwipeLeft;
GestureView.SwipeRight += HandleSwipeRight;
GestureView.Touch += HandleTouch;
}
}
第一部分显示了事件注册到GestureView。我们还有一个自定义的Command绑定,当发生Touch事件时将被调用。让我们添加以下EventHandler函数:
public void HandleTouch(object sender, EventArgs e)
{
if (SelectedCommand != null)
{
var cell = CarouselScroll.GetSelectedItem(SelectedIndex);
SelectedCommand.Execute(cell);
}
}
public async void HandleSwipeLeft(object sender, EventArgs e)
{
if (((CarouselScroll.ScrollX + CarouselScroll.Width) < (CarouselScroll.Content.Width - CarouselScroll.Width)) && !_animating)
{
_animating = true;
SelectedIndex++;
await CarouselScroll.ScrollToAsync(CarouselScroll.ScrollX + Width + 20, 0, true);
_animating = false;
}
}
public async void HandleSwipeRight(object sender, EventArgs e)
{
if (CarouselScroll.ScrollX > 0 && !_animating)
{
_animating = true;
SelectedIndex--;
await CarouselScroll.ScrollToAsync(CarouselScroll.ScrollX - Width - 20, 0, true);
_animating = false;
}
}
}
HandleTouch函数将简单地调用CarouselScroll中的GetSelectedItem函数。这意味着我们从视图中获取绑定对象,并将其用作传递给SelectCommand执行参数。HandleSwipeLeft函数将增加所选索引的值 1,并滚动视图的整个宽度。记住,每个子项占据视图的整个宽度和高度,因此为了移动到下一个子项,我们必须水平滚动宽度。
然后我们有HandleSwipeRight函数,它将执行与HandleSwipeLeft相反的操作,并沿相反方向滚动。在每个滑动函数中,我们也执行一个检查,看看我们是否在起始子项或最后一个子项上。
恭喜你,你刚刚构建了你的第一个自定义布局。现在让我们构建用户界面的其余部分,看看我们如何使用它。
构建用户界面
现在是时候构建用户界面屏幕了;我们将从构建视图模型开始。在FileStorage.Portable项目中,添加一个名为ViewModels的新文件夹,添加一个名为MainPageViewModel.cs的新文件,并实现以下内容:
public class MainPageViewModel : ViewModelBase
{
#region Private Properties
private string _descriptionMessage = "Welcome to the Filing Room";
private string _FilesTitle = "Files";
private string _exitTitle = "Exit";
private ICommand _locationCommand;
private ICommand _exitCommand;
private ISQLiteStorage _storage;
#endregion
}
我们在这个视图模型中包含了ISQLiteStorage对象,因为当这个视图模型被创建时,我们将创建数据库表。别忘了我们需要为所有private属性实现公共属性;以下是有助于你开始的两个属性:
#region Public Properties
public ICommand LocationCommand
{
get
{
return _locationCommand;
}
set
{
if (value.Equals(_locationCommand))
{
return;
}
_locationCommand = value; OnPropertyChanged("LocationCommand");
}
}
public ICommand ExitCommand
{
get
{
return _exitCommand;
}
set
{
if (value.Equals(_exitCommand))
{
return;
}
_exitCommand = value; OnPropertyChanged("ExitCommand");
}
}
#endregion
然后我们添加剩余的属性。我们在构造函数中调用SetupSQLite函数来设置数据库,如下所示:
#region Constructors
public MainPageViewModel (INavigationService navigation, Func<Action, ICommand> commandFactory,
IMethods methods, ISQLiteStorage storage) : base (navigation, methods)
{
_exitCommand = commandFactory (() => methods.Exit());
_locationCommand = commandFactory (async () => await Navigation.Navigate(PageNames.FilesPage, null));
_storage = storage;
SetupSQLite().ConfigureAwait(false);
}
#endregion
private async Task SetupSQLite()
{
// create Sqlite connection _storage.CreateSQLiteAsyncConnection();
// create DB tables await _storage.CreateTable<FileStorable>
CancellationToken.None);
}
}
SetupSQLite函数负责创建到本地数据库的异步连接,并从FileStorable对象构建一个表。
现在让我们为这个视图模型构建页面。在FileStorage项目中添加一个名为Pages的新文件夹,添加一个名为MainPage.xaml的新文件,并实现以下内容:
<?xml version="1.0" encoding="UTF-8"?>
<ui:ExtendedContentPage
x:Class="FileStorage.Pages.MainPage"
BackgroundColor="White"
Title="Welcome">
<ui:ExtendedContentPage.Content>
<Grid x:Name="Grid" RowSpacing="10" Padding="10, 10, 10, 10" VerticalOptions="Center">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Image x:Name="Image" Source="files.png" HeightRequest="120"
WidthRequest="120" Grid.Row="0" Grid.Column="0"/>
<Label x:Name="DesciptionLabel" Text="{Binding DescriptionMessage}"
TextColor="Black" HorizontalOptions="Center" Font="Arial, 20"
Grid.Row="1" Grid.Column="0"/>
<Button x:Name="LocationButton" Text="{Binding FilesTitle}"
Command="{Binding LocationCommand}"
Style="{StaticResource ButtonStyle}" Grid.Row="2" Grid.Column="0"/>
<Button x:Name="ExitButton" Text="{Binding ExitTitle}"
Command="{Binding ExitCommand}" Style="{StaticResource ButtonStyle}"
Grid.Row="3" Grid.Column="0"/>
</Grid>
</ui:ExtendedContentPage.Content>
</ui:ExtendedContentPage>
记得我们的自定义控件ExtendedContentPage吗?
We are going to use this for all pages so that every page has alert functionality connected with its view-model. The following line gives the reference to our custom control:
We have to declare a new `ExtendedContentPage` like the following:
<ui:ExtendedContentPage
The rest of the page is the same as previous projects. A simple **Grid** contains an image, label, and two buttons. Now implement the following for `MainPage.xaml.cs`:
public partial class MainPage : ExtendedContentPage, INavigableXamarinFormsPage
{
#region Constructors
public MainPage (MainPageViewModel model) : base(model)
{
BindingContext = model;
InitializeComponent ();
}
#endregion
#region INavigableXamarinFormsPage interface
public void OnNavigatedTo(IDictionary<string, object> navigationParameters)
{
this.Show (navigationParameters);
}
#endregion
}
We are able to assign the `BindingContext` property through the constructor because we are registering this item inside the IoC container.Now we move on to the next page, where we will be including the `CarouselView`. We will also be loading in our files that are saved locally in our database. Our first step is to create a new view-model for each view that is going to appear in the `CarouselView`. Add a new file to the `ViewModels` folder called `FileItemViewModel.cs` and implement the following:
public class FileItemViewModel : ViewModelBase
{
#region Private Properties
private string _fileName;
private string _contents;
#endregion
#region Public Properties
public string FileName
{
get
{
return _fileName;
}
set
{
if (value.Equals(_fileName))
{
return;
}
_fileName = value; OnPropertyChanged("FileName");
}
}
public string Contents
{
get
{
return _contents;
}
set
{
if (value.Equals(_contents))
{
return;
}
_contents = value; OnPropertyChanged("Contents");
}
}
#endregion
#region Public Methods
public void Apply(FileStorable file)
{
FileName = file.Key ?? string.Empty;
Contents = file.Contents ?? string.Empty;
}
#endregion
#region Constructors
public FileItemViewModel(INavigationService navigation, IMethods methods) :
base(navigation, methods) { }
#endregion
}
It is very simple, just two properties to contain the filename and text contents of the file. These two items will be saved in a `FileStorable` object in our local database. We have an `Apply` function that will take a `FileStorable` object to load the properties of the view-model.Now let's build the page. Inside the `ViewModels` folder, add a new file called `FilesPageViewModel.cs` and implement the following:
public class FilesPageViewModel : ViewModelBase
{
#region Private Properties
private readonly Func<FileItemViewModel> _fileFactory;
private readonly ISQLiteStorage _storage;
private readonly SynchronizationContext _context;
private ICommand _editFileCommand;
private ICommand _createFileCommand;
private bool _noFiles;
#endregion
}
We have two commands for editing a file, which will be bound to the custom binding `SelectCommandProperty` on the `CarouselView`. When a user touches the current child on the `CarouselLayout`, this command will be invoked.Notice the `SynchronizationContext` property?This will be used for threading purposes to ensure we update the `ObservableCollection` on the main UI thread.Now let's add the public properties as follows:
region Public Properties
public Subject
public ICommand EditFileCommand
{
get
{
return _editFileCommand;
}
set
{
if (value.Equals(_editFileCommand))
{
return;
}
_editFileCommand = value;
OnPropertyChanged("EditFileCommand");
}
}
public ICommand CreateFileCommand
{
get
{
return _createFileCommand;
}
set
{
if (value.Equals(_createFileCommand))
{
return;
}
_createFileCommand = value;
OnPropertyChanged("CreateFileCommand");
}
}
public bool AnyFiles
{
get
{
return _noFiles;
}
set
{
if (value.Equals(_noFiles))
{
return;
}
_noFiles = value;
OnPropertyChanged("AnyFiles");
}
}
public ObservableCollection
endregion
### Tip
Don't forget that we only need a `public` property for the properties that are going to be bound to the view.
We have an `ObservableCollection` of type `FileItemViewModel;` so, for every file we pull from the database, a new view-model will be created to show the details on the child view of the `CarouselView`. We also have an `IObservable` property called `DataChanges`; every time we update the `ObservableCollection`, we will publish a new event through the stream, and because we will be binding this property to the `CarouselView`, the list of children will be structured accordingly.Now let's add the constructor as follows:
region Constructors
public FilesPageViewModel(INavigationService navigation, Func<Action










浙公网安备 33010602011771号