今天把JDi/Server测试做完.终于有了时间来写写关于这个项目总结.关于我在博客上Post这些文章内容都是从实际项目应用而来.当然有些问题解决方案也是不断被重复设计修改.期间也碰到诸多问题.也曾为客户端在UI设计和具体的实现倍感困惑过.下午在Product Ower UI原型设计讨论会上. 设计团队针对内部一个孵化SNS项目原型设计做了三套设计方案.从IPhone到Android 再到Windows phone.顿时不禁有一个疑问. 如果抛开市场定位 用户群体.和业务需求等等.单单从开发人员角度来说 什么样的APP才能称之为一个用户能够接受并乐于使用的呢?
在发表讨论前 注意我这个命题成立条件[如上加粗字体].如果只看后半段.当然很多市场.运营 甚至是最终用户都会否掉这个命题的成立条件.本篇暂且假设它是成立的.因此我在内部的Wiki上向IOS和Android WP团队发起一个讨论.把这个讨论结果生成一个Wish List 按照优先级做了排列 如下:
App Wish List: [1]:贴近用户自身实用的功能 [2]: APP自身的稳定性 [3]: 良好的用户交互体验 [4]: 创意 …… |
从一个数据列表来以小见微来说明这个问题.:
在Windows phone中目前获取动态数据列表数据方式.云端/IsolataSTorage/网络请求/Socket通信/SQLCED等.作为常见客户端而言.最多采用就是网络请求的方式来从服务器端拉去数据.在采用 WebClient或HttpWebREquest方式或是云端存储 涉及网络请求数据时.在Begin-End异步处理模型中应首先保证我们代码块做了正确的事.并能够在出现异常时也能正常执行流程反馈在UI上. 一个APP健壮性在反馈编码上最小层级就是代码块异常处理.
但是在异常处理中很多Dev都关注程序自身功能性Bug.客户端明确定义是能够处理自身以及和外界网络发生交互时任何Exception.请求网络中断. 请求超时. 服务器端Server返回404 Not Found. 数据解析异常等等.这都需要客户端处理并UI中有所呈现.代码块的完整异常处理是APP健壮性最小单位.
对于比较常见或是不可见的异常可以通过服务器端统一过滤编码并明确返回客户端提示.这样做目的是为了保证不把程序异常直接堆栈信息暴露给用户. 并采用客户端日志记录. 当然在客户端异常存在某种条件下照成不可见的APP崩溃的情况. 所以在UnHandlerException()方法中当APP崩溃必须退出时也得给用户一个友好的退出提示.
now.当顺利拿到数据.通过最常用的ListBox来呈现.在DataTemplate数据模板中包含文字和图片信息.在呈现文字信息之前加载时间内必须给出动态加载进度条. 但注意在用户操作BackUpPress键时必要处理.
在数据呈现时.这里必须提到ListBox自身性能问题.当通过绑定ItemSource数据源时,如果呈现ObserverCollection<T>没有限定数量.会照成ListBox在UI操作发生延迟或是得不到及时响应.ObserverCollection<T>如果存在10000数据项.在后台中Silverlight必须将其实例化10000个ListItem实例项.才能通过UI呈现出现.短时间APP内存使用剧增.出现性能瓶颈.
所以绑定时数据源ObserverCollection<T>应尽可能的小的数据量20-30.尽量减少每次更新代价. 如果无法避免类似在需要大型数据绑定时 可以考虑在后台实现动态加载数据源方式.在数据呈现过程中请慎用ValueConverter转换器.
ValueConverter转换器慎用: ValueConverter转换器采用自定义代码实现转换数据格式或额外操作. 导致无法在实际元素呈现之前确定页面布局和数据缓存. 特别是在出现大数量时ValueConverter简直就是延时器. 可能导致UI线程长时间阻塞得不到响应.大大降低用户体验.请慎用! |
在动态加载实现上可以参考IPhone加载数据列表的方式.当用拖拽数据列表到达底部时则动态Loading数据.尽量保证UI可操作.不要让用户把时间浪费Loading等待上.
说到这想起奇艺客户端不禁想吐槽一下[善意的:) 奇艺客户端刚出第一个版本时 通过首页进入到在Pivot枢轴呈现的同步剧场界面时[没有截到图]:
在Pivot控件中用户左右移动PivotItem页时总是提示用户Loading界面. 要知道用户没有多少耐心去等待.而且用户一发生PivotItem切换是都会加载数据过程.当你在完全断开网络连接情况.在打开客户端时你会发现里面没有任何数据……这证明每次用户操作数据都是即时的. 用户体验不太友好 用户期望:
A:第一次可以允许进行加载数据.但当再次打开界面应具有可操作数据. B:每次切换PivotItem枢轴页加载等待时间这个Loading…太不合时宜了.如果已经加载过应存在客户端中.当存在数据更新时才动态更新列表数据. |
数据缓存对于数据列表价值主要体现在友好的用户交互体验上.所以一个健全的客户端是无法缺少一个有效缓存系统支撑的.数据缓存在客户端中主要解决两个问题:
缓存解决的问题: [1]:效率,缓存本身就是为了提高性能,不要因为缓存的原因反而减低了性能 [2]:数据的实时性,对于缓存数据的实时性,各种缓存设计都有自己的策略,比如设置过期时间、定时刷新等。具体采用哪种策略和具体的业务不无关系 |
数据列表缓存: A:客户端能够存数据保证每次在断开连接情况有数据可以操作.实现数据缓存 B:能够实现数据列表以动态更新.提高UI用户交互体验 |
针对缓存需求设计缓存如下:
如上缓存设计则采用以单一过期时间作为缓存策略.并把该缓存更新时间的选项暴露给用户选择. 用户也可以清空本地客户端缓存数据:
ok.从如上设计图不难看出.当第一次发起请求时做了一方面把数据还回客户端同时在客户端建立数据缓存.当页面再次加载时检查缓存时间是否过期.如果已经过期则重新请求服务器拉取数据.并更新本地数据缓存数据.其实在设计这个缓存时.客户端缓存更新需要暴露两个接口.一个利用缓存时间策略有客户端自动更新.当然作为用户也需要把更新列表和缓存的选项以刷新的方式暴露出来 例如在列表ApplicationBar下添加刷新操作:
但是总体来看这种更新缓存的方式我们来总结这种缓存设计的优缺点:
缓存设计优缺点: A:缓存更新策略单一.更新缓存方式统一 编程容易控制.能满足基本的客户端数据缓存操作. B:在缓存时间内存在无法获取即时数据缺陷. |
这个设计存在一个缺陷就是在缓存存储时间内.服务器端更新的数据在用户没有操作刷新按钮的情况下.无法即时获取服务器端最新数据.客户端只能通过不断轮询的方式来实现数据更新.这种方式主要因服务器没有建立主动数据更新消息推送机制导致.更新间隔的频率可以保持在30S内一次操作. 在Windows phone 中针对这种情况采用Push Notification推送通知的机制来完善这个缺陷:
在使用Windows phone Push Notification推送通知服务.服务器端建立一个WebService[WCF服务]当服务器端数据发生更新时则通过WCF 服务向云端的推送通知服务发送一条数据更新的消息.由推送通知服务把消息响应给客户端.通过消息处理程序客户端解析数据更新消息后.重新连接服务器主动加载数据.并更新本地客户端缓存.
推送通知具体工作流程如下:数字具体的工作步骤
但是这种方式在实际应用中也存在具体的几个问题.在客户端目的就是就是服务器端能够在数据更新时建立一种主动向客户端推送通知机制. 通知客户端数据已经更新.由客户端重新发起数据请求更新本地缓存.Push Notification推送服务完全能够完成这项工作.但是在实际测试中依然发现一个问题. 其实在设计时我们忽略Windows phone 推送通知处理方式是批处理方式传递.
处理的事务可能不是即时的。 推送通知的及时性将得不到保证,而且将由该推送通知服务决定何时将通知传递给客户端;只能被动的通过数据更新方式通过监听服务来触发通知. |
但是这种好处在建立主动更新的机制.
测试中发现.在服务器端把所有数据都做压缩处理.也就是每次请求数据对服务器代价很低.如果没有涉及大数量和即时更新要求.第一种轮询缓存设计足以够用.推送通知这种方式主要在建立一种主动更新的机制.把更新的操作交给应用程序后台来做.但在必要的时候则完全越过推送通知方式 直接访问WCF服务来检测数据是否更新.也是可行的.
如上讨论客户端缓存实现设计两种方式.各有利弊.针对这种方式 先实现轮询的方式数据缓存的设计.实际项目中.当建立客户端缓存时把数据列表的数据序列化后一Json文件的方式存在独立存储空间内.并对json文件进行管理.IsolateStroage开辟一块独立的区域.当我们一个APP设计多个模块使用数据缓存.在同一个层级上管理多个Json文件就会发生混乱.
针对这种情况.可以采用以模块为分类在独立存储空间建立文件夹目录.对应目录下放着该模块缓存所有数据Json文件.这样对独立存储强制的分区的目的主要有两个.一个便于文件管理和分类. 另外一个就是减少数据访问查找范围提高读写大文件时性能.
在BuyTicket模块.当拿到数据文件并完成序列化成Json格式文件.执行存储时格式是:
Cache Json File: “[模块目录名称]/[序列化Json文件名称].Txt” |
发现在编程中直接操作Json文件或目录极容易出错.我们把每一个建立缓存文件封装成实体.然后真正文件操作前通过IsolateStorageSeting字典表于具体的CacheEntity缓存实体进行关联.那么编程操作就是是封装的缓存实体CacheEntity对象.在以缓存时间单一策略下CacheEntity需要如下属性:
- [DataContract]
- public class CacheEntity
- {
- /// <summary>
- /// 缓存起始时间
- /// </summary>
- [DataMember]
- public string StartDate { get; set; }
- /// <summary>
- /// 缓存周期
- /// </summary>
- [DataMember]
- public string CacheDate { get; set; }
- /// <summary>
- /// 唯一存储Key
- /// </summary>
- [DataMember]
- public string CacheKey { get; set; }
- /// <summary>
- /// 存储模块
- /// </summary>
- [DataMember]
- public string CacheDirName { get; set; }
- /// <summary>
- /// 存储文件名称
- /// </summary>
- [DataMember]
- public string CacheFileName { get; set; }//文件名称
- /// <summary>
- /// 缓存数据类型
- /// </summary>
- [DataMember]
- public string CacheContext { get; set; }//缓存数据类型
- }
CacheEntity缓存实体类中StartData标识缓存开始的时间. DataCache则是用户决定缓存周期.设置有默认值. 唯一存储Key则用来标识在IsolateStorageSeting中标识CacheEntity.而缓存数据类型CaCheContext是在获取Json文件后反序列化时需要指定源数据反序列类型.
有了CacheEntity封装则可以在建立一个CacheManager容器来管理缓存数据. CacheManager中要实现缓存实体的CRUD 之外要设置缓存周期等 添加一个数据缓存:
- public class CacheManager
- {
- /// <summary>
- /// 缓存是否过期
- /// </summary>
- /// <param name="getCacheEntity">Cache Entity</param>
- /// <returns>Is out Of Date</returns>
- public static bool CacheEntityIsOutDate(CacheEntity getCacheEntity)
- {
- bool isOutOfDate = false;
- if (getCacheEntity != null)
- {
- DateTime currentDate = DateTime.Now;
- TimeSpan getTimeSpan = currentDate - Convert.ToDateTime(getCacheEntity.StartDate);
- int compareValue = getTimeSpan.CompareTo(new TimeSpan(0, Convert.ToInt32(getCacheEntity.CacheDate), 0));
- if (compareValue == -1)
- isOutOfDate = false;//未过期
- else
- isOutOfDate = true;//过期
- }
- return isOutOfDate;
- }
- /// <summary>
- /// 添加缓存
- /// </summary>
- /// <param name="getCacheEntity">cache Entity</param>
- public static bool AddCacheEntity(CacheEntity getCacheEntity)
- {
- bool isCache = false; 34: if (getCacheEntity != null)
- isCache=UniversalCommon_operator.AddIsolateStorageObj(getCacheEntity.CacheKey,getCacheEntity);
- return isCache;
- }
- }
可以看到通过CacheManager能够实现对缓存CacheEntity字典表管理方式.这样大大简化我们编程直接操作数据文件复杂性.有了CacheManager后还需要对底层Json文件进行管理定义一个FileManager类.在BuyTicket模块添加一个TicketList数据缓存.这是需要在IsolateStorage独立存储创建BuyTicket文件夹并在该文件夹下建立一个TicketListJson.txt格式Json文件.添加文件操作:
- public class FileManager
- {
- /// <summary>
- /// 创建文件目录
- /// </summary>
- /// <param name="dirName">目录名称</param>
- public static bool CreateDirectory(string dirName)
- {
- bool isCreateDir = false;
- if (!string.IsNullOrEmpty(dirName))
- {
- using (IsolatedStorageFile getIsolatedStorageFile = IsolatedStorageFile.GetUserStoreForApplication())
- {
- if (!getIsolatedStorageFile.DirectoryExists(dirName))
- {
- //Not Exist And CreatDir
- getIsolatedStorageFile.CreateDirectory(dirName);
- isCreateDir = true;
- }
- }
- }
- return isCreateDir;
- }
- /// <summary>
- /// 创建文件
- /// </summary>
- /// <param name="dirname">目录名称</param>
- /// <param name="filename">文件名称</param>
- /// <param name="getDataStream">文件内容</param>
- /// <returns>是否创建</returns>
- public static bool CreateFile(string dirname, string filename, Stream getDataStream)
- {
- bool isCreateFile = false;
- if (!string.IsNullOrEmpty(filename))
- {
- #region No Directory
- using (IsolatedStorageFile getIsolatedStorageFile = IsolatedStorageFile.GetUserStoreForApplication())
- {
- string filepath = filename + ".txt";
- if (getIsolatedStorageFile.FileExists(filepath))
- getIsolatedStorageFile.DeleteFile(filepath);//Exist To Delete
- //Create File
- isCreateFile = true;
- getIsolatedStorageFile.CreateFile(filepath);
- getDataStream.Seek(0, SeekOrigin.Begin);
- //Write Data
- using (StreamReader getReader = new StreamReader(getDataStream))
- {
- using (StreamWriter getWriter = new StreamWriter(getIsolatedStorageFile.OpenFile(filepath, FileMode.Open, FileAccess.Write)))
- {
- getWriter.Write(getReader.ReadToEnd());//Save Data
- }
- }
- }
- #endregion
- }
- return isCreateFile;
- }
- }
这样我们就是先对缓存和底层操作IsolateStorage独立存储空间Json文件进行管理. Now.在ListCache要加载这些数据并添加缓存中.在View_model操作如下:
- public void LoadDataCacheFilmTicketDate()
- {
- this.dataCacheTicketCol.Clear();
- //假设访问网络连接 获得如下连接数据
- this.dataCacheTicketCol.Add(new FilmTicket() { FilmName = "猩球崛起", TicketPrice = "80" });
- this.dataCacheTicketCol.Add(new FilmTicket() { FilmName = "青蜂侠", TicketPrice = "180" });
- this.dataCacheTicketCol.Add(new FilmTicket() { FilmName = "拯救大兵瑞恩", TicketPrice = "80" });
- this.dataCacheTicketCol.Add(new FilmTicket() { FilmName = "Secret Men", TicketPrice = "80" });
- //添加缓存
- #region 缓存存储
- if (dataCacheTicketCol.Count > 0)
- {
- List<FilmTicket> getTickelist = new List<FilmTicket>();
- foreach (FilmTicket getFilmTicket in dataCacheTicketCol)
- getTickelist.Add(getFilmTicket);
- if (getTickelist.Count > 0)
- {
- using (MemoryStream getStreamObj = new MemoryStream())
- {
- //Cache Store Entity
- CacheEntity ReBackEntity = new CacheEntity()
- {
- StartDate = DateTime.Now.ToString(),
- CacheKey = "FilmIndexList",
- CacheDate = CacheManager.SettingCacheDate(),
- CacheContext = typeof(List<FilmTicket>).ToString(),
- CacheDirName = "BuyTicket",
- CacheFileName = "FilmIndexList"
- };
- CacheManager.AddCacheEntity(ReBackEntity);
- //Store Cache File
- Serialiser.PartSerialise(getStreamObj, getTickelist);
- bool isCache = FileManager.CreateFile("BuyTicket", "FilmIndexList", getStreamObj);
- //Store Cache Status
- UniversalCommon_operator.AddIsolateStorageObj("CacheFilmIndexList", true);
- }
- }
- }
- #endregion
- }
在添加数据缓存时.首先建立一个CacheEntity实体数据.分别设置缓存周期,开始时间默认为现在. 存储数据以及存储数据类型List<FilmTicket>. 以便在反序列化通过反射方式来生成数据集合.并把该实体添加CacheManager管理容器中. 添加缓存后需要对底层Json执行存储操作.并与CacheEntity进行关联.关联的方式是Key命名和CacheFileName一致.最后表示缓存存储的状态.
缓存建立成功.在下一次加载列表时会检查独立存储空间缓存数据.如果没有过期则加载独立存储中缓存数据. 加载缓存数据:
- #region 加载缓存
- if (UniversalCommon_operator.IsolateStorageKeyIsExist("FilmIndexList"))
- {
- CacheEntity getcacheEntity = CacheManager.QueryCacheEntityObj("FilmIndexList");
- if (getcacheEntity != null)
- {
- #region Cache Date Operator
- if (CacheManager.CacheEntityIsOutDate(getcacheEntity))
- getTicketIndex_ViewModel.LoadCurrentFilmList();//过期
- else
- {
- //Read date from cache
- string jsonStr = FileManager.ReadFile(getcacheEntity.CacheDirName, getcacheEntity.CacheFileName);
- if (!string.IsNullOrEmpty(jsonStr))
- {
- //Assembly get Object Type
- MemoryStream getJsonStream = new MemoryStream(System.Text.UTF8Encoding.UTF8.GetBytes(jsonStr));
- Assembly currentAssembly = Assembly.Load("WelfareLife.Common");
- Type currentType=currentAssembly.CreateInstance(getcacheEntity.CacheContext).GetType();
- List<FilmTicket> getTicketlist = Serialiser.PartDeSerialise(currentType, getJsonStream) as List<FilmTicket>;
- if (getTicketlist.Count > 0)
- {
- //清空数据
- this.getTicketIndex_ViewModel.filmTicketCollection.Clear();
- getTicketlist.ForEach(x => getTicketIndex_ViewModel.filmTicketCollection.Add(x));
- }
- }
- }
- #endregion
- }
- }
- #endregion
首先通过CacheEntity获取缓存Json格式数据.通过反射的方式获取反序列化转换数据类型.转换成功后 更新ViewModle中ObserverCollection<T>集合数据.并反馈到UI上.这样就成功获取缓存中存储数据.如果缓存已经过期.则重新请求服务器端数据.并更新本地缓存.这样一个简单以缓存周期为单一策略的缓存构建完成.由于本片篇幅有限.关于推送通知的设计的缓存将不再本篇说明.如有要源码请Email我.
如上从Windows phone APP稳定性和用户交互体验以一个数据列表方式从代码块异常处理,.数据性能.以及客户端缓存系统构建3个角度.来说明这个问题.篇幅毕竟有限.本篇核心放在缓存系统构建方案设计和实现上.实际操作场景中.第一种轮询的方式简单实用.编程控制统一.而关于推送通知这种方式在服务器端交付时并无差异.但是相对比较复杂.对于比较频繁或对服务器要求比较APP中则才会体现与轮询上优势. 一个具有良好用户体验的客户端对一个完善缓存系统是依赖的.当然我们其实做了四种方案.本文中只是拿了最为特殊两个方案进行比对.欢迎各位提供更好的解决方案.
本篇源码见附件。
Windows phone应用开发: Windows phone应用开发[1]-Text To speech Windows phone应用开发[2]-数据缓存 |
相对于PC端数据缓存设计思路可选择性在移动客户端并不是特别多. 主要因为移动客户端能够使用资源和用户需求都远低于Pc端Application.类似WP中能够用来支持数据存储SQLCE数据库和IsolateStorage独立存储空间.等完全没有Pc端应用程序开放性.从一个数据列表来说对缓存要求极为简单:
so.良好的用户体验 是建立在客户端数据缓存基础之上的……
可能这个排列对最终用户而言存在争议. 在Wiki中很多人提到创意应该走在APP最前端.也就是设计阶段时应该考虑到.但是如果从开发角度而言.无论什么样的创意一旦从开发流程来执行时.并无任何差异.开发流程在每一个固定的MileStore里程碑中能够持续交付功能.并能保证整个APP自身稳定性.再次基础上建立良好的UI用户交互体验.整个APP开发使命也就算基本完成了.
如果对于Windows phone的APP满足上面的WishList.需要注意哪些方面问题?
针对这个问题.开发者很容易陷入各种各样开发细节之中.这些细节大多是他们眼前要面对或即将要解决的问题.而无法从这些细节的泥沼之中抽身出来从整个APP全局角度来思考这个问题.那针对如上APP需要 和自己理解.侧重APP稳定性和用户交互体验.可以通过以下几点来切入.
这些问题从Windows phone 一个数据列表说起.