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

Friends Radar - Windows 8 的基于地理位置的朋友发现应用

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2012年10月23日

CPOL

12分钟阅读

viewsIcon

20769

本文介绍了一款可以查找附近朋友的应用。它是一个App创新竞赛的参赛作品。

引言

正如标题所示,Friends Radar 是一款基于地理位置的社交应用。基本上,它允许你从你的 Microsoft 帐户(或 Facebook、Foursquare)中选择一些朋友,并在他们靠近时收到通知。此外,用户还可以搜索一定距离内(例如 100 米)的朋友。当然,朋友管理、应用设置以及设备之间的同步等功能也需要包含在内。它还将能够与搜索魅力(Search charm)和锁屏界面集成。但请记住,我才刚刚开始实现它,它还不能治愈癌症。

背景

你需要具备 C#、WinRT 应用开发或其他 XAML 技术的知识,才能理解本文中的代码片段。

为什么要选择 WinRT?

选择 WinRT 基本上意味着我必须遵循特定的 UI 样式指南,在编码时处于沙箱环境中(我相信有方法可以跳出盒子,但大多数情况下,都可以预期到沙箱限制),并且在很大程度上可以不用管应用托管/安装/更新。作为一个喜欢控制的程序员,我失去了一些自由。但这也意味着我可以将更多精力集中在核心功能上,而不用担心设置技术等问题。此外,WinRT 还内置了联系人管理功能,在某些场景下可以简化很多事情。如果你需要在应用中实现推送通知,Windows 通知服务(Windows Notification Service)简直是福音。另外,我真的很喜欢 Metro 风格,它相当清新、富有艺术感,并且在 UI 方面与传统的思维方式大相径庭。

地点,地点,地点!!!

我大学时主修 GIS。大部分课程都让我觉得枯燥。我当时坚信,每次我去上地图绘制课,都会有一只小猫死去。所以我几乎逃避了所有这样的课程。但有一门课引起了我极大的兴趣——GPS。它让我着迷,并想象出许多它的应用。其中一个就是查找我朋友的位置,因此有了 Friends Radar。

如何获取位置信息?

要使 Friends Radar 正常工作,我们需要获取某个用户的朋友的近乎实时位置信息,有两种方式。如果朋友也安装了 Friends Radar,我们可以让他们广播自己的位置给他们的朋友,包括我们的用户。当然,用户必须意识到他们的位置信息会发布给他们的朋友。另一种方式则需要更多工作。Facebook 有位置 API,Foursquare 也有。我们可以利用这两个平台。但问题是,这两个平台的位置信息很难被认为是实时的或接近实时的。而且 Foursquare 还没有 WinRT SDK,甚至第三方也没有。所以我们只能选择 Facebook。

鉴于此,至少目前为止,我们将只采用第一种方法——获取位置信息并广播给选定的朋友。

在 WinRT 中获取位置信息非常简单,如下所示

var locator = new Geolocator
{
    DesiredAccuracy = PositionAccuracy.Default;
};
Geoposition position = await loc.GetGeopositionAsync();
App.LastKnownLocation = position;    //Cache the position to be used by manual friends search

//or alternatively
locator.PositionChanged += (sender, e) =>
{
    Geoposition position = e.Position;
    App.LastKnownLocation = position;    //Cache the position to be used by manual friends search
};

虽然这并不特别困难,但有一个问题需要考虑。获取位置信息可能需要相当长的时间,而且我们需要很频繁地访问位置信息。所以我们需要缓存“最后已知位置”,当用户手动搜索一定范围内的朋友时,我们使用缓存的“最后已知位置”启动搜索,一旦收到实际的位置固定,我们就将其与“最后已知位置”进行比较。如果“最后已知位置”在实际固定范围内,我们就将结果作为最终结果,否则,我们就重新开始搜索。

但对于位置广播,我们不需要使用缓存的位置,但位置仍然需要缓存,因为它可以用于手动搜索朋友。

另一个可能的问题是位置精度,我们可能需要采用不同的方法来处理低精度模式,例如 Wi-Fi 三角测量(100-350 米)、IP 解析(>= 25,000 米)。为了简化起见,我们暂时忽略这个问题,以后再重新考虑。

选择你的朋友

鉴于 Windows 8 拥有“人物中心”(People hub),我们可以使用 Windows 内置的联系人 API 让用户选择将哪些朋友添加到 Friends Radar 中。但它功能有限,这就是我选择 Live SDK 的原因。当然,如果我们使用 Facebook 或 Foursquare,我们就需要能够从这些服务中选择朋友。但目前,我们使用 Live SDK。考虑到 Facebook 可以连接到 Microsoft Account,Facebook 的联系人系统得到了部分支持。

朋友选择的 UI 灵感来自于 Windows 8 的“人物中心”。但由于检索账户图片可能需要一些时间,因此只显示文本信息,如下所示

Contact Picker Mockup

用 WinRT XAML 实现这种分组 UI 并不容易。没有内置控件可以做到这一点。但幸运的是,我找到了一个博客文章,讨论了如何实现类似“人物中心”的列表。其核心思想是使用 DataTemplateSelector 来不同地显示组和项。通过使用 DataTemplateSelector(或 ItemTemplateSelector),可以动态地控制项目在某些方面的外观,每个项目都不一样
it is to use DataTemplateSelector to display groups differently from items. By using DataTemplateSelector (or ItemTemplateSelector), one can dynamically control certain aspect of the items on a per-items
basis, like below

class ItemOrHeaderSelector: DataTemplateSelector
{
    public DataTemplate Group { get; set; }
    public DataTemplate Item { get; set; }
    
    protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
    {
        var itemType = item.GetType();
        // Use generic type name to determine whether an item is a group
        var isGroup = itemType.Name == "Group`1" && 
             itemType.Namespace == "NogginBox.WinRT.Extra.Collections";

        // Disable headers so they can't be selected
        var selectorItem = container as SelectorItem;
        if(selector != null)
        {
            selectorItem.IsEnabled = !isGroup;
        }
        
        return isGroup? Group : Item;
    }
}

XAML 看起来大概是这样

<selectors:ItemOrHeaderSelector
    x:Key="FilmItemOrHeaderSelector"
    Item="{StaticResource FilmItem}"
    Group="{StaticResource FilmHeader}" />
...
<GridView ItemsSource="{Binding ItemsWithHeaders}" 
  ItemTemplateSelector="{StaticResource FilmItemOrHeaderSelector}" SelectionMode="None" />

我基本上模仿了 UI,除了少数细微的差异。修复这些不一致并使其几乎完全相同并不容易,因为我不是设计师。代码有点hacky,我会稍微打磨一下再发布。

当用户从 Microsoft 帐户中选择好朋友后,将代表用户向这些朋友发送一封电子邮件,让他们下载并安装 Friends Radar 到他们的电脑上(如果他们还没有的话)。这是通过 Windows Azure Mobile Services 的最新功能实现的——在服务器脚本中使用 SendGrid 发送电子邮件。

var sendgrid = new SendGrid('<< account name >>', '<< password >>');
sendgrid.send({
    to: '<< email of a friend >>',
    from: 'notifications@friends-radar.azure-mobile.net',    
    subject: 'XXX invites you to join Friends Radar',
    text: 'XXX has looped you into his/her friends radar, confirm this request by installing Friends Radar if this is your Microsoft Account email.' + '\r\Otherwise, you can use this invitation code - xxxxxx to accept this request after installing Friends Radar'
     }, function (success, message) {
    if(!success) {
        console.error(message);
    }
});    

这对我来说似乎极其微不足道。云真是太棒了!

当朋友安装了 Friends Radar 后,他们就会出现在用户的雷达屏幕上,并在朋友搜索中显示出来。

分组

用户可以也应该能够将他们的朋友分组,以便他们可以选择性地只收听特定朋友的位置更新——在 Friends Radar 中称为“脉冲”(pulses)。

情境化的朋友互动

当朋友出现在给定距离/范围内(即在雷达屏幕上找到)后,用户可以通过发送 Toast 通知、电子邮件、发起即时消息(IM)对话、视频通话(前提是 Skype 打开其 API)甚至推特来进行互动。可用的互动应该是情境化的,比如,现在是 12:00,嘿,午餐时间到了,你可以邀请朋友共进晚餐。或者附近有一家星巴克,你可以发起一次咖啡邀请。或者用户可以通过推送通知向朋友发送简单的消息。总之,可能性很多。

后端 - Windows Azure Mobile Services

尽管名字有点难听,但 Windows Azure Mobile Services 非常好用,是编写云连接应用的福音。几分钟内,你就可以让应用与 Windows Azure 来回交换数据。它做得非常出色。因此,对于后端,选择了 Windows Azure Mobile Services,因为它简单、可靠且开发速度快。

我特别喜欢它的动态架构功能。感觉像魔法一样。我将通过代码截图和代码来解释。

创建新的 Windows Azure Mobile Services 表时,只有一个“id”列,如下所示

New Table

但启用动态架构后,你可以在新数据首次插入时更改表架构。你可以在“Configure”(配置)选项卡下启用动态架构,如下所示

Enable Dynamic Schema

默认情况下是开启的。

然后在你的代码中,只需向模型添加更多成员,如下所示

public class User
{
	public int Id { get; set; }

	[DataMember(Name = "firstname")]
	public string FirstName { get; set; }

	[DataMember(Name = "lastname")]
	public string LastName { get; set; }

	[DataMember(Name = "email")]
	public string Email { get; set; }
}

神奇的是,当你通过 IMobileServiceTable<User>.InsertAsynce 向表中插入数据时,Windows Azure Mobile Services 会捕捉到变化并相应地更新架构。真是
太棒了!

在本文的第二版中,我主要关注了后端,因为我发现模仿内置联系人选择器后,应用的前端部分对我来说尤其困难。毕竟,我不是设计师。但即使 Windows Azure Mobile Service 非常简单,我也遇到了一些障碍。幸运的是,这些障碍都已消除。

表架构 

friends-radar 的架构有 11 个表,如下图所示

下面是每个表的列模型,用 C# 代码表示

 
    /// <summary>
    /// Used to record user activities
    /// </summary>
    [DataTable(Name = "activities")]
    public class Activity
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "deviceId")]
        public int DeviceId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }

        /// <summary>
        /// The type of the activity, could be friend invitations, 
        /// interactions (a dinner/drink invitation, etc.), or 
        /// a friend shows up on the radar
        /// </summary>
        [DataMember(Name = "type")]
        public string Type { get; set; }

        [DataMember(Name = "description")]
        public string Description { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        [DataMember(Name = "latitude")]
        public double Latitude { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        /// <summary>
        /// The human-readable address corresponding to the location 
        /// specified by Latitude and Longitude
        /// </summary>
        [DataMember(Name = "address")]
        public string Address { get; set; }
    }
    
    /// <summary>
    /// Used to store the channel url for each user and installation in Mobile Services
    /// </summary>
    [DataTable(Name = "devices")]
    public class Device
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "installationId")]
        public string InstallationId { get; set; }

        [DataMember(Name = "channelUri")]
        public string ChannelUri { get; set; }
    }
    
    /// <summary>
    /// Used to store info of friend group
    /// </summary>
    [DataTable(Name = "groups")]
    public class Group
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "name")]
        public string Name { get; set; }

        [DataMember(Name = "expirationDate")]
        public DateTime ExpirationDate { get; set; }

        [DataMember(Name = "created")]
        public DateTime Created { get; set; }

        [DataMember(Name = "userId")]
        public string UserId;
    }
    
    /// <summary>
    /// Used to store info of members of a friend group
    /// </summary>
    [DataTable(Name = "groupMembers")]
    public class GroupMember
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "groupId")]
        public int GroupId { get; set; }

        [DataMember(Name = "memberUserId")]
        public string MemberUserId { get; set; }

        [DataMember(Name = "groupOwnerUserId")]
        public string GroupOwnerUserId { get; set; }
    }
    
    /// <summary>
    /// Represents an invite requesting another user to join Friends Radar
    /// </summary>
    [DataTable(Name = "invites")]
    public class Invite
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "fromUserId")]
        public string FromUserId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }

        [DataMember(Name = "fromUserName")]
        public string FromUserName { get; set; }

        [DataMember(Name = "fromImageUrl")]
        public string FromImageUrl { get; set; }

        [DataMember(Name = "toUserName")]
        public string ToUserName { get; set; }

        [DataMember(Name = "toUserEmail")]
        public string ToUserEmail { get; set; }

        /// <summary>
        /// If a friend is not using Microsoft Account, they can 
        /// use invite code to join your friends radar
        /// </summary>
        [DataMember(Name = "inviteCode")]
        public string InviteCode { get; set; }

        [DataMember(Name = "approved")]
        public bool Approved { get; set; }
    }
    
    /// <summary>
    /// Used to store info of friends interactions
    /// </summary>
    [DataTable(Name = "interactions")]
    public class Interaction
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "fromUserId")]
        public string FromUserId { get; set; }

        [DataMember(Name = "fromDeviceId")]
        public int FromDeviceId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }

        /// <summary>
        /// The type of the interaction, like a dinner invitation, 
        /// a drink invitation, a get-together call, etc. 
        /// </summary>
        [DataMember(Name = "type")]
        public string Type { get; set; }

        [DataMember(Name = "message")]
        public string Message { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        [DataMember(Name = "latitude")]
        public double Latitide { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        /// <summary>
        /// The human-readable address corresponding to the location 
        /// specified by Latitude and Longitude
        /// </summary>
        [DataMember(Name = "address")]
        public string Address { get; set; }
    }
    
    [DataTable(Name = "operations")]
    public class Operation
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        /// <summary>
        /// The operation id. Right now, there is only two
        /// 1. for updateLocation - ask all of the friends to update their location
        /// 2. for search - ask all of the friends to report whether they meat the 
        ///    certain search criterias
        /// </summary>
        [DataMember(Name = "operationId")]
        public int OperationId { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        #region Search criterias
        [DataMember(Name = "latitude")]
        public double Latitude { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        [DataMember(Name = "searchDistance")]
        public double SearchDistance { get; set; }

        [DataMember(Name = "friendName")]
        public string FriendName { get; set; } 
        #endregion
    }
    
    /// <summary>
    /// A class used to store information about users in Mobile Services
    /// </summary>
    [DataTable(Name = "profiles")]
    public class Profile
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        private string _name;
        [DataMember(Name = "name")]
        public string Name { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "city")]
        public string City { get; set; }
        
        [DataMember(Name = "state")]
        public string State { get; set; }

        [DataMember(Name = "created")]
        public string Created { get; set; }

        [DataMember(Name = "imageUrl")]
        public string ImageUrl { get; set; }

        [DataMember(Name = "accountEmail")]
        public string AccountEmail { get; set; }
    }
    
    /// <summary>
    /// Represents a update of user's location info
    /// </summary>
    [DataTable(Name = "pulses")]
    public class Pulse
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }
       [DataMember(Name = "userName")]
       public string UserName { get; set; }  
        [DataMember(Name = "deviceId")]
        public int DeviceId { get; set; }

        [DataMember(Name = "latitude")]
        public double Latitude { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        /// <summary>
        /// The human-readable address corresponding to the location 
        /// specified by Latitude and Longitude
        /// </summary>
        [DataMember(Name = "address")]
        public string Address { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        #region Search criteria related properties
        [DataMember(Name = "searchDistance")]
        public int SearchDistance { get; set; }

        /// <summary>
        /// How much time difference we can tolerate when do a search
        /// </summary>
        [DataMember(Name = "searchTimeTolerance")]
        public int SearchTimeTolerance { get; set; }

        [DataMember(Name = "friendName")]
        public string FriendName { get; set; } 
        #endregion
    }
    
    /// <summary>
    /// Store friend relationships data
    /// One thing to remember is that when user A and user B are friends,
    /// then the record of (fromUserId = <user A id>, toUserId = <user B id>)
    /// and (fromUserId = <user B id>, toUserId = <user A id>) should both 
    /// exists. This simplify things a bit when determining the relationship 
    /// of two users
    /// </summary>
    [DataTable(Name = "relationships")]
    public class Relationship
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "fromUserId")]
        public string FromUserId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }
    }
    
    /// <summary>
    /// Store user settings
    /// </summary>
    [DataTable(Name = "settings")]
    public class Setting
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public int UserId { get; set; }

        [DataMember(Name = "key", IsRequired = true)]
        public string Key { get; set; }

        [DataMember(Name = "value", IsRequired = true)]
        public string Value { get; set; }
    }
    

服务器脚本

Windows Azure Mobile Service 服务器脚本(我知道这有点拗口)是基于 Node.js 的。我以前听说过 NodeJS,但从未尝试过用它编码。服务器脚本是我第一次接触 NodeJS,现在我成了它的忠实粉丝。我喜欢它的简单性和异步特性。

但正是在服务器脚本中,我遇到了一些障碍。也许这是自然的,因为 NodeJS 和服务器脚本对我来说都很新。

下面列出了一些重要的脚本,如有需要,会附带注释和详细解释。

用于 activities 表的脚本

// Insert script 
function insert(item, user, request) {
    
    if (!item.when) {
        item.when = new Date();
    }
    item.userId = user.userId;
    request.execute();

} 

//   Read (aka query) script
function read(query, user, request) {
    
    // Add a additional query clause to ensure that user only gets to see their own activities
    query.where({userId: user.userId});
    request.execute();

}

用于 devices 表的脚本

// Insert script
function insert(item, user, request) {
    // we don't trust the client, we always set the user on the server
    item.userId = user.userId;
    // require an installationId
    if (!item.installationId || item.installationId.length === 0) {
        request.respond(400, "installationId is required");
        return;
    }
    // find any records that match this device already (user and installationId combo)
    var devices = tables.getTable('devices');
    devices.where({
        userId: item.userId,
        installationId: item.installationId
    }).read({
        success: function (results) {
            if (results.length > 0) {
                // This device already exists, so don't insert the new entry,
                // update the channelUri (if it's different)
                if (item.channelUri === results[0].channelUri) {
                    request.respond(200, results[0]);
                    return;
                }
                // otherwise, update the notification id 
                results[0].channelUri = item.channelUri;
                devices.update(results[0], {
                    success: function () {
                        request.respond(200, results[0]);
                        return;
                    }
                });
            }
            else
            {
                request.execute();
            }
        }
    }); 
}

用于 groupMembers 表的脚本

// Insert script 
var groups = tables.getTable('groups');
var groupMembers = tables.getTable('groupMembers');

function insert(item, user, context) {
    // Make sure that the user can only add members to the group that they own by querying groups table
    groups.where({ id: item.groupId, userId: user.userId })
        .read({
            success: function (results) {
                if (results.length === 0) {
                    context.respond(400, 
                        'You cannot add member to a group which you do not own');
                    return;
                }
                
                groupMembers.where({ groupId: item.groupId, 
                    memberUserId: item.memberUserId })
                    .read({
                        success: function (results) {
                            if (results.length > 0) {
                                context.respond(400,
                                    'The user is already in the group');
                                return;
                            }
                            
                            item.groupOwnerUserId = user.userId;
                            context.execute();
                        }
                    });
            }
        });
    

} 

//  Delete script
var groupMembers = tables.getTable('groupMembers');

function del(id, user, context) {
    // Make sure that user can only delete member from the group that they own
    groupMembers.where({ id: id, groupOwnerUserId: user.userId })
        .read({ 
            success: function (results) {
                if (results.length === 0) {
                    context.respond(400, 
                        'You can only delete member from the groups you own');
                    return;
                }
                
                context.execute();
            }
         });
}

用于 groups 表的脚本

// Insert script
var groups = tables.getTable('groups');

function insert(item, user, context) {
    item.userId = user.userId;
    
    // Make sure that the group with the same name does not already exist
    groups.where({ name: item.name, userId: user.userId })
        .read({
            success: function (results) {
                if (results.length > 0) {
                    context.respond(400,
                        'The group with the name "' + item.name + '" already exists');
                        return;
                }
                
                context.execute();
            }
        });
}   

// Delete script
var groups = tables.getTable('groups');

function del(id, user, context) {
    groups.where({ id: id, userId: user.userId }).read({
        success: function (results) {
            if (results.length === 0) {
                context.respond(400, 
                    'You are not authorized to delete groups which you do not own');
                return;
            }
            // delete all members of the group
            var sqlGroupMembers = 'DELETE FROM groupMembers WHERE groupId = ?';
            mssql.query(sqlGroupMembers, [id], {
                error: function (error) {
                    // The query might error if we have no members in that group
                    // which is fine there is nothing to delete in that case
                }
            });
            context.execute();
        }
    })
    

}
 

用于 interactions 表的脚本

// Insert script 
var relationships = tables.getTable('relationships');
var devices = tables.getTable('devices');
var profiles = tables.getTable('profiles');

function insert(item, user, context) {
    
    item.userId = user.userId;
    
    // Make sure that the user can only interact with their friends
    relationships.where({ fromUserId: user.userId, toUserId: item.toUserId })
        .read({ 
            success: function (results) {
                if (results.length === 0) {
                    context.respond(400, 
                        'You can only interact with your friends');
                    return;
                }
                
                context.execute();
                
                // Notify the friend that's being interacted with
                sendPushNotifications();
            }
         });
    
    function sendPushNotifications() {
        profiles.where({ userId: user.userId })
            .read({
                success: function (profileResults) {
                    var profile = profileResults[0];
                    devices.where({ userId: item.toUserId })
                        .read({
                            success: function (deviceResults) {
                                deviceResults.forEach(function (device) {
                                    push.wns.sendToastImageAndText01(
                                        device.channelUri, {
                                        image1src: profile.imageUrl,
                                        text1: item.message
                                    });
                                });
                            }
                        });
                }
            });
        
    }
}

用于 invites 表的脚本

这是一个棘手的实现。但代码相当清晰,所以不需要解释。  

// Insert script 
var invites = tables.getTable('invites');
var devices = tables.getTable('devices');
var profiles = tables.getTable('profiles');
var relationships = tables.getTable('relationships');

function insert(item, user, context) {
    var fromUserId = item.fromUserId, toUserId = item.toUserId;
    var isUsingInviteCode = item.inviteCode !== undefined && item.inviteCode !== null;
    if (fromUserId !== user.userId) {
        context.respond(400, 'You cannot pretend to be another user when you issue an invite');
        return;
    }
    
    if (toUserId === user.userId) {
        context.respond(400, 'You cannot invite yourself');
        return;
    }
    
    if (isUsingInviteCode) {
        // We're using invitation code instead of using Friends Radar
        invites.where({ inviteCode: item.inviteCode, fromUserId: fromUserId})
            .read({ success: checkRedundantInvite });
    }
    else {
        relationships
            .where({ fromUserId: fromUserId, toUserId: toUserId })
            .read({ success: checkRelationship});
    }
    
    function checkRelationship(results) {
        if (results.length > 0) {
            context.respond(400, 'Your friend is already on your radar');
            return;
        }
        
        invites.where({ toUserId: toUserId, fromUserId: fromUserId})
            .read({ success: checkRedundantInvite });
    }
    
    function checkRedundantInvite(results) {
        if (results.length > 0) {
            context.respond(400, 'This user already has a pending invite');
            return;
        }
        
        // Everything checks out, process the invitation
        processInvite();
    }
    
    function processInvite() {
        item.approved = false;
        context.execute({
            success: function(results) {
                context.respond();
                if (isUsingInviteCode === false) {
                    // Send push notification
                    getProfile(results);
                }
            }
        });
    }
    
    function getProfile(results) {
        profiles.where({ userId : user.userId }).read({
            success: function(profileResults) {
                sendNotifications(profileResults[0]);
            }
        });
    }

    function sendNotifications(profile) {
        // Send push notifictions to all devices registered to 
        // the invitee
        devices.where({ userId: item.toUserId }).read({
            success: function (results) {
                results.forEach(function (device) {
                    push.wns.sendToastImageAndText01(device.channelUri, {
                        image1src: profile.imageUrl,
                        text1: 'You have been invited to "Friends Radar" by ' + item.fromUserName
                    }, {
                        succees: function(data) {
                            console.log(data);
                        },
                        error: function (err) {
                            // The notification address for this device has expired, so
                            // remove this device. This may happen routinely as part of
                            // how push notifications work.
                            if (err.statusCode === 403 || err.statusCode === 404) {
                                devices.del(device.id);
                            } else {
                                console.log("Problem sending push notification", err);
                            }
                        }
                    });
                }); 
             }
        });
    }    

} 

// Update script 
var invites = tables.getTable('invites');
var relationships = tables.getTable('relationships');

function update(item, user, context) {
    
    invites.where({ id : item.id }).read({
        success : function (results) {
            if (results[0].toUserId !== user.userId) {
                context.respond(400, 'Only the invitee can accept or reject an invite');
                return;
            }
            processInvite(item);
        }
    });
    
    function processInvite(item) {
        
        if (item.approved) {
            // If an invite is updated and marked approved, that means the 
            // invitee has accepted, so add relationships between the two 
            // users
            relationships.insert({
                fromId: item.fromUserId,
                toUserId: user.userId
            }, { 
                success: deleteInvite
            });
            relationships.insert({
                fromId: user.userId,
                toUserId: item.fromUserId
            });
        } else {
            // If an invite is updated, but approved !== true then we assume the invite
            // is being rejected. An alternative approach would have the client delete
            // the invite directly 
            deleteInvite();
        }
    }
    
    function deleteInvite() {
        // We have taken the necessary action on this invite, 
        // so delete it
        invites.del(item.id, {
            success: function () {
                context.respond(200, item);
            }
        });
    }
}

用于 operations 表的脚本  

operations 表 仅供用户执行某些与朋友相关的操作。如operations 表 C# 模型中的注释所述,目前只有两个操作:搜索和更新位置。对于这两个操作,我们使用推送通知来告诉所有朋友立即向后端发送一个脉冲来更新他们的位置信息,或者检查他们是否符合某些条件,然后发送脉冲。在这两种情况下,我们都使用推送通知参数的text 属性来存储信息。这可以被看作是一种分布式计算,因为所有朋友都会更新他们的位置信息来形成搜索结果。

// Insert script
var operationIds = {
        updateLocation: 1,
        search: 2
    };
var devices = tables.getTables('devices');
var relationships = tables.getTables('relationships');
var settings = tables.getTables('settings');

function trimString (str) {
    return str.replace(/^\s*/, "").replace(/\s*$/, "");
}

function isString (obj) {
    return typeof obj === 'string';
}

// Get the search criterias from item or (if not found) from the settings table
function getSearchCriterias (item, userId, callback) {
    var friendName = item.friendName;
    if (friendName && isString(friendName) &&
        trimString(friendName) !== '' ) {
        friendName = trimString(friendName).toLowerCase();
    } else {
        friendName = '';
    }
    var criterias = {
            searchDistance: item.searchDistance,
            friendName: friendName,
            latitude: item.latitude,
            longitude: item.longitude
        };
    if (item.hasOwnProperty('searchDistance')) {
        callback(criterias);
    }
    else {
        settings.where({ userId: userId })
            .read({
                success: function (results) {
                    results.forEach(function (setting) {
                        if (setting.key === 'searchDistance') {
                            criterias.searchDistance = criterias.searchDistance || 
                                parseFloat(setting.value);
                        }
                    });
                    
                    callback(criterias);
                }
            });
    }
}

function insert(item, user, context) {
    item.userId = user.userId;
    if (!item.when) {
        item.when = new Date();
    }
    context.execute();
    relationships.where({user: user.userId})
        .read({
            success: function (friendResults) {
                var operationId = item.id;
                if (operationId === operationIds.search) {
                    // Get search criterias and ask friends to see whether they meet 
                    // the search criterias
                    getSearchCriterias(item, user.userId, function (criterias) {
                        friendResults.forEach(function (friend) {
                            devices.where({ userId: friend.toUserId })
                                .read({
                                    success: function (deviceResults) {
                                        deviceResults.forEach(function (device) {
                                            push.wns.sendToastText04(device.channelUri, {
                                                text1: 'search: ' + 
                                                    'latitude=' + criterias.latitude + ', ' +
                                                    'longitude=' + criterias.longitude + ', ' + 
                                                    'friendName=' + criterias.friendName + 
                                                    'searchDistance=' + criterias.searchDistance
                                            });
                                        });
                                    }
                                });
                        });
                    });
                } 
                else if (operationId === operationIds.updateLocation) {
                    // Simply ask all the friends to update their location info
                    friendResults.forEach(function (friend) {
                            devices.where({ userId: friend.toUserId })
                                .read({
                                    success: function (deviceResults) {
                                        deviceResults.forEach(function (device) {
                                            push.wns.sendToastText04(device.channelUri, {
                                                text1: 'updateLocation'
                                            });
                                        });
                                    }
                                });
                        });
                }
            }
        });

}

用于 profiles  表的脚本

// Insert script
var profiles = tables.getTable('profiles');
var settings = tables.getTable('settings');


function populateDefaultSettings (userId) {
    var sql = 'INSERT INTO settings ' + 
            "SELECT '" + userId + "', key, value from settings " + 
            "WHERE userId = 'defaultSettingUser'";
    mssql.query(sql, [], {
        error: function () {
            console.log('Error populating default settings for user with id "' + 
                userId + '"');
        }
    });
}

function insert(item, user, context) {

    if (!item.name && item.name.length === 0) {
        context.respond(400, 'A name must be provided');
        return;
    }

    if (item.userId !== user.userId) {
        context.respond(400, 'A user can only insert a profile for their own userId.');
        return;
    }

    // Check if a user with the same userId already exists
    profiles.where({ userId: item.userId }).read({
        success: function (results) {
            if (results.length > 0) {
                context.respond(400, 'Profile already exists.');
                return;
            }

            // No such user exists, add a timestamp and proces the insert
            item.created = new Date();
            context.execute();
            populateDefaultSettings();
        }
    });
}

用于 pulses 表的脚本  

IMO,这是最棘手的一个,特别是读取操作。 因为我需要支持pulses 表的搜索功能,所以我不得不采用一种严重依赖服务器脚本内部工作原理的 hack。我通过打印读取脚本的query 对象的。内容/结构发现了这个 hack。通过它,我发现query 对象的filters (查询条件)具有 LINQ 查询的结构 
usersTable.Where(userId = 'userA' && name = 'Named');

有点像下面的图

所以我可以遍历 filters 对象来获取查询的成员-值对来执行我的搜索。这是我遇到的最大障碍。 很高兴它被移除了。实现此功能的函数是 findMemberValuePairsFromExpression

另一个障碍是计算两个经纬度对之间的距离,但谷歌搜索解决了这个问题。实现此功能的函数是 calcDistanceBetweenTwoLocation

让我思考的最后一个问题是如何用涉及计算的几个复杂条件进行搜索。事实证明,query 对象的where 方法可以接受一个函数谓词来完成此操作。太棒了!!!  

// Insert script 
var devices = tables.getTable('devices');
var profiles = tables.getTable('profiles');
var relationships = tables.getTable('relationships');

function checkUserName (item, userId, callback) {
    if (item.userName) {
        callback();
    }
    profiles.where({userId: userId})
        .read({
            success: function (results) {
                item.userName = results[0].name;
                callback();
            }
        });
}

function insert(item, user, context) {
    item.userId = user.userId;
    if (!item.when) {
        item.when = new Date();
    }
    
    checkUserName(item, user.userId, function () {
        context.execute({
            success: getProfile
        });
    });
    
    function getProfile() {
        context.respond();
        profiles.where({ userId : user.userId }).read({
            success: function(profileResults) {
                sendNotifications(profileResults[0]);
            }
        });
    }
    
    function sendNotifications(profile) {
        relationships.where({fromUserId: user.userId}).read({
            success: function(friends) {
                friends.forEach(function(friend) {
                    // Send push notifications to all devices registered to a friend
		    devices.where({ userId: friend.toUserId }).read({
			success: function (results) {
			    results.forEach(function (device) {
                                push.wns.sendToastImageAndText01(device.channelUri, {
                                    image1src: profile.imageUrl,
                                    text1: 'pulse: from=' + item.userId + ',' + 
                                            'deviceId= ' + device.id + ',' +
                                            'latitude=' + item.latitude + ',' +
                                            'longitude=' + item.longitude
                                }, {
                                    success: function(data) {
                                        console.log(data);
                                    },
                                    error: function (err) {
                                        // The notification address for this device has expired, so
                                        // remove this device. This may happen routinely as part of
                                        // how push notifications work.
                                        if (err.statusCode === 403 || err.statusCode === 404) {
                                            devices.del(device.id);
                                        } else {
                                            console.log("Problem sending push notification", err);
                                        }
                                    }
                                });
                            }); 
                         }
                    });
                });
            }
        });
        
    }
}


// Read script
var relationships = tables.getTable('relationships');
var settings = tables.getTable('settings');
var pulses = tables.getTable('pulses');

function isObject(variable) {
    return variable !== null && 
        variable !== undefined && 
        typeof variable === 'object';
}

// Print an object recursively
function printObject(obj, objName, printer) {
    if (!isObject(obj)) {
        return;
    }
    var prefix = objName === undefined || objName === ''? 
                '' : objName + '.';
    printer = printer || console.log;
    for(var name in obj) {
        if (obj.hasOwnProperty(name)) {
            var prop = obj[name];
            if(isObject(prop)) {
                printObject(prop, prefix + name);
            }
            else {
                var str = prefix + name + ': ' + prop;
                printer(str);
            }
        }
    }
}

// Find all the member-value pairs from the expression object
function findMemberValuePairsFromExpression (expr, ret) {
    if (!isObject(expr)) {
        return null;
    }
    ret = ret || {};
    for (var name in expr) {
        if (expr.hasOwnProperty(name)) {
            var prop = expr[name];
            if (name === 'parent') { // Ignore parent property since it's added by us
                continue;
            }
            else if (name === 'left') { // member expression are in the left subtree
                if (isObject(prop)) {
                    prop.parent = expr;
                    findMemberValuePairsFromExpression(prop, ret);
                }
            }
            else if (name === 'member') {
                // Found a member expression, find the value expression 
                // by the knowledge of the structure of the expression
                var value = expr.parent.right.value;
                ret[prop] = value;
            }
        }
    }
    
    if (expr.parent) {
        // Remove the added parent property
        delete expr.parent;
    }
    
    return ret;
}

function toRad(Value) {
    /** Converts numeric degrees to radians */
    return Value * Math.PI / 180;
}

function calcDistanceBetweenTwoLocation (lat1, lon1, lat2, lon2) {
    //Radius of the earth in:  1.609344 miles,  6371 km  | var R = (6371 / 1.609344);
    var R = 3958.7558657440545; // Radius of earth in Miles 
    var dLat = toRad(lat2-lat1);
    var dLon = toRad(lon2-lon1); 
    var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * 
            Math.sin(dLon/2) * Math.sin(dLon/2); 
    var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
    var d = R * c;
    return d * 1000; // Translate to metre
}

// Get the filters component from query object and 
// find the member-value pairs in it
function findMemberValuePairsFromQuery (query) {
    var filters = query.getComponents().filters;
    return findMemberValuePairsFromExpression(filters);
}

// Get the search criterias from filter expression or (if not found) from the settings table
function getSearchCriterias (filterExpression, userId, callback) {
    var criterias = {
            searchDistance: filterExpression.searchDistance,
            searchTimeTolerance: filterExpression.searchTimeTolerance,
            friendName: filterExpression.friendName
        };
    if (filterExpression.hasOwnProperty('searchDistance') &&
        filterExpression.hasOwnProperty('searchTimeTolerance')) {
        callback(criterias);
    }
    else {
        settings.where({ userId: userId })
            .read({
                success: function (results) {
                    results.forEach(function (setting) {
                        if (setting.key === 'searchDistance' || 
                            setting.key === 'searchTimeTolerance') {
                            criterias[setting.key] = criterias[setting.key] || 
                                parseFloat(setting.value);
                        }
                    });
                    
                    callback(criterias);
                }
            });
    }
} 

function trimString (str) {
    return str.replace(/^\s*/, "").replace(/\s*$/, "");
}

function isString (obj) {
    return typeof obj === 'string';
}

function read(query, user, context) {
    
    relationships
        .where({ fromUserId: user.userId })
        .select('toUserId')
        .read({
            success: function (results) {
                var filterExpression = findMemberValuePairsFromQuery(query);
                if (filterExpression.hasOwnProperty('latitude') && 
                    filterExpression.hasOwnProperty('longitude')) {
                    // This is a search operation   
                    var lat = filterExpression.latitide, lon = filterExpression.longitude;
                    getSearchCriterias(filterExpression, user.userId, function (criterias) {
                        /* Do the search by filtering with the search criteria */
                        var searchTimeToleranceAsMilliseconds = criterias.searchTimeTolerance * 1000;
                        var timestampAsMilliseconds = new Date().getTime() - 
                                searchTimeToleranceAsMilliseconds;
                        var timestamp = new Date(timestampAsMilliseconds);
                        var friendName = criterias.friendName;
                        if (isString(friendName) &&
                            trimString(friendName) !== '') {
                            friendName = trimString(friendName).toLowerCase();        
                        } else {
                            friendName = null;
                        }
                        
                        // Query with a function to do the search
                        pulses.where(function (friends) {
                                var isAFriend = this.userId in friends;
                                if (!isAFriend) {
                                    return false;
                                }
                                if (friendName && 
                                    trimString(this.friendName)
                                    .toLowerCase().indexOf(friendName) === -1) {
                                    return false;
                                }
                                var distance = calcDistanceBetweenTwoLocation(
                                    this.latitude, this.longitude, lat, lon);
                                
                                return distance <= criterias.searchDistance && 
                                    this.when >= timestamp;
                            }, results)
                            .read({
                                success: function (searchResults) {
                                    context.respond(200, searchResults);
                                }
                            });
                    });
                }
                else {
                    // Attach an extra clause to the user's query 
                    // that forces it to look only in users the user
                    // is a friend of
                    query.where(function (friends) {
                        return this.userId in friends;
                    }, results);
                    context.execute();
                }
                
            }
        });
    

}

其他脚本

其他服务器脚本相对简单,因此在此省略。 

链接

学习服务器脚本,我认为以下两个链接会很有帮助

 [1] Mobile Services 服务器脚本参考   

 [2] TypeScript 声明文件,适用于 Windows Azure Mobile Services 服务器脚本   

推送,拜托!     

Windows 通知服务(Windows Notification Services)结合 Windows Azure Mobile Services 的服务器脚本功能,可以出色地满足您的推送通知需求。只需遵循以下链接: 

[1] Windows Store 的 Mobile Services 推送通知入门

[2] 通过 Mobile Services for Windows Store 向用户推送通知  

我在一个小时内完成了基本功能,大部分时间都花在了配置上。所以一点也不难。 

通知什么?  

目前,只有当朋友靠近你或邀请你时,你才会收到 toast 通知。将来,你还会收到朋友离开定义的距离范围的通知。你可以向他们发送告别消息。

做出姿态  

我还在研究这一部分。试图弄清楚应用应该支持什么样的姿态。如果你有任何建议,请在评论区留言。 

雷达屏幕 

在搜索附近的朋友时,雷达屏幕会显示出来。我知道这是一个比喻,但如果没有真正的雷达屏幕在运行,“Friends Radar”这个名字似乎,嗯,没有名字。

动画  

没有流畅的旋转动画,雷达屏幕就不算 proper。虽然我不是 deadmau5 那样可以动画整个建筑,但我将制作一个 Metro 风格的雷达屏幕,扫描天空寻找好朋友。

地图叠加 

没有哪个开发者会称自己的应用是基于位置的,而不使用地图。所以地图将被叠加在雷达屏幕上,并在地图上层叠显示所发现朋友的信息。正如预期的那样。

语义缩放 

这是一个附加功能,可能在第二版中我会让它与雷达屏幕一起工作。基本思想是让用户在缩放时看到更多朋友的信息,在缩小时看到更少的信息。 

动态磁贴 

Windows 应用商店(以及 Windows Phone)应用的一个特别之处就是动态磁贴。对于 Friends Radar,动态磁贴将显示有多少朋友在附近。就是这么简单。   

搜索和锁屏集成 

用户应该能够使用搜索魅力(search charm)来查找他们附近的朋友,如下所示 

通过锁屏集成,用户可以一眼看到周围有多少朋友。这被称为“可瞥视性”(glancibility)。 

应用设置  

这当然很重要。你必须允许用户自定义你的应用。就 Friends Radar 而言,用户可以管理朋友,设置默认搜索距离范围和位置广播的频率,以及指定是否与锁屏集成等。

要点 

Windows 8 将会大放异彩,而位置信息将变得越来越重要。因此,Friends Radar 应运而生! 

历史 

  • 2012/10/23 - 第一版
  • 2012/10/23 - 小幅修正
  • 2012/10/26 - 添加了动态磁贴部分 
  • 2012/11/22 - 添加了后端实现、分组和朋友互动部分。修正了一些拼写错误  
  • 2012/11/23 - 添加了“通知什么”部分,并更新了“选择你的朋友”和“用于 operations 表的脚本”子部分,使某些内容更清晰 
  • 2012/11/25 - 修复了损坏的格式 
 
© . All rights reserved.