Android性能优化之解密ZAKER,网易云阅览等新闻应用的内容缓存加载方式

Android性能优化之解密ZAKER,网易云阅读等新闻应用的内容缓存加载方式

我是比较关注时事的, 每天都会花一点事件去看看新闻什么的. 因此类似ZAKER, 网易云阅读等这类的资讯聚合类应用是我的钟爱, 并且这些应用也确实做得很好,值得学习! 前面一篇文章, 讲了缓存的一些构思. 之前也写过LRUCache类的一些缓存实践, 但那只是放在应用的缓存中,并不适合做长期的缓存.  这次我们来实践完整的例子, 模仿ZAKER那类应用是如何加载一条新闻的,并且如何缓存这条新闻的.


写在前面, 本篇文章只是其中一种实现方式, 仅为阐述思路, 并不代表最优的做法. 条条大路通罗马嘛.


实现效果

效果图( 如果打不开, 请复制图片地址到地址栏打开. 源码在文章底部. )

Android性能优化之解密ZAKER,网易云阅览等新闻应用的内容缓存加载方式


先说效果, 实际使用中, 当用户第一次打开一条新闻, 这条新闻出来的是文字, 然后显示默认加载中的图片, 用户慢慢的滑动屏幕浏览新闻, 同时新闻中的图片也慢慢的加载出来, 做的更人性化的, 甚至会将加载中的图片都显示百分比或者进度信息, 让用户知道这张图片大概还差多少就加载完毕. 然后退出当前的页面, 点击别的标题, 继续看别的新闻... 一直重复这个操作.


当因为某些原因, 网络关闭了, 但用户无聊, 在公交上想看看之前浏览过的新闻, 但为了省流量关闭了手机网络而且公交上没有WIFI.  用户打开应用, 点开之前看过的新闻标题, 进入依然能看到之前的完整的新闻, 图片文字什么都有.


到了这里, 我们应该明白, 用户看过的新闻文字和图片都缓存在本地了.上篇文章写过, 图片一般适合通过File缓存在本地, 大篇的文字适合放在数据库. 没错, 就这么实现~


接下来希望将具体的思路说清楚, 那么自己实现起来,或者看下面的示例代码, 都能有更好的理解.


具体的思路

1, 如何实现

ZAKER的新闻显示, 是使用WebView做载体, 然后使用自定义的HTML模版, 将服务器传递到客户端的数据填充本地的HTML模版去显示. 好看的HTML模版, 包含不少通用的CSS, JS, 是一个优秀前端开发人员的产品, 作为移动开发, 我们并不需要在这方面了解太多. 如果有需要, 大可叫公司的前端开发人员提供对应的模板. 所以我们下面的例子, 只是简单的HTML模板, 方便阐述原理.


1)  在服务器获取用于填充HTML模板的一条新闻内容的数据后, 我们还应当得到这条新闻所包含的图片信息, 比如图片名字, 图片id, 图片的下载链接等等... 将文字内容存入数据库.


2)  然后通过一些方法( 一般是JavaScript ) 在WebView的页面控制 先显示文字,和加载中的默认图片. 同时在本地新建线程去下载这些图片.


3)  每有一张图片下载完成, 就将图片存入本地存储空间中. 并且将图片的信息放入数据库或其他地方. 这样以后要用这张图片, 直接用图片的下载链接作为查询条件, 先去数据库查找, 如果存在, 就获取该图片下载链接所对应的本地存储路径信息. 如果路径有效则使用, 无效或查找无果, 则重新下载.


4)  上面下载完一张图片后还没结束, 最后, 每下载完一张图片, 还要在WebView中将默认的图片替换成所下载的图片. 整个过程大致如此.


2, 需要一点别的知识 - JavaScript

看了上面四个步骤, 相信在从服务器获取数据, 对数据库SQLite进行存取数据, WebView加载HTML源码等等这些是毫无难度的. 但有一些地方,确实本地代码很难完成的, 需要借助JavaScript. WebView是支持JavaScript的,因为它能完成很多页面的工作, 比如监听WebView的网页滚动坐标, 控制页面滚动等常见的场景, 类似网页开发的 lazyload( 延迟加载图片, 异步加载图片等 ) 效果都借助了js实现. 而且JavaScript还能和本地代码进行交互被调用, 使得WebView变得异常强大. 比如上面的图片异步加载( 先显示默认图片, 然后图片下载完去替换默认图片 ), 在页面获取图片信息, 调用本地代码去下载图片等, 这些都是实现的关键地方, 依靠JavaScript完成.


但是要注意, JavaScript是容易产生安全漏洞的地方, 稍有不慎,就会成为被攻击的入口. 所以不建议随意去用.


说了上面一堆, 应该不难理解我们接下来所要实现的功能了吧.但有几个地方, 在这里进行说明:


1, WebView是可以直接加载本地资源的, 比如SD卡, assets, raw等文件夹里的都可以.


2, 多线程使用数据库, 比如SQLite. 必须注意并发同步等问题. 对于不是高并发, 线程数也不多的, 最简单的可以用关键字synchronized去解决. 不然很容易抛错.


3, 图片保存和读取,要注意文件的体积大小. 具体应用中,推荐的方案是服务器已经根据移动端对图片进行了压缩优化再传递, 这样客户端可以降低处理的复杂度.

设想一下, 有限大小的屏幕中, 一堆文字不占什么空间流量, 但却给了几张原图大小的图片过来, 是毫无意义的. 内容中显示小图, 如果用户点击图片弹出, 或者 点击保存原图, 这时才另外下载原图, 既保证交互的舒适感, 也保证流量和性能都没有被浪费.


So 直接上代码, 结合代码的注释和上面的说明, 就容易理解多了. 


先是用到的2个页面的简单XML, 

activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.alextam.webviewdemo.MainActivity"
    tools:ignore="MergeRootFrame" 
    >
    <TextView 
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:background="#88888888"
        android:text="测试WebView缓存"
        android:textColor="#FFFFFFFF"
        android:textSize="18sp"
        android:gravity="center"
        android:layout_alignParentTop="true"
        />
    
    <Button 
        android:id="@+id/btn_start"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        android:text="打开页面"
        android:layout_centerInParent="true"
        />
    
</RelativeLayout>


webview_act_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    <WebView
        android:id="@+id/wv_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />

</RelativeLayout>

入口Activity, MainActivity :

这个类很简单, 就是创建本地缓存的文件夹用于存放新闻中的图片.

/**
 * Created on 5/21/2015
 * @author Alex Tam
 *
 */
public class MainActivity extends Activity {
	private Button btn_start;
	private String rootPath;
	public static final String SEPERATOR = "/";
	
	@Override
	protected void onCreate(Bundle savedInstanceState) 
	{
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		
		init();
		
	}
	
	private void init()
	{
		btn_start = (Button)findViewById(R.id.btn_start);
		
		createCacheFolder();
		
		btn_start.setOnClickListener(new OnClickListener() {
			@Override
			public void onClick(View v) {
				if(createCacheFolder())	//先检测是否成功创建本地缓存文件夹
				{
					Intent goIntent = new Intent(MainActivity.this,WebViewActicvity.class);
					startActivity(goIntent);
				}
			}
		});
	}
	
	
	
	//创建本地缓存文件夹 - 存放图片
	private boolean createCacheFolder()
	{
		if(Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()))
		{
			rootPath = Environment.getExternalStorageDirectory().getAbsolutePath() 
					+ SEPERATOR + "WebViewDemo";
			
			File cFile = new File(rootPath);
			if(!cFile.exists())
			{
				cFile.mkdir();
			}
			return true;
		}
		else
		{
			t(this, "无法创建本地文件夹,请插入SD卡");
			
			return false;
		}
	}
	
	
	public static final void t(Context context , String c)
	{
		Toast.makeText(context, c, Toast.LENGTH_SHORT).show();
	}
	

}

具体显示效果的Activity, WebViewActicvity:

public class WebViewActicvity extends Activity{
	private WebView wv_main;
	//MAP - 存放要显示的图片信息
	private  ConcurrentHashMap<String, String> map = new  ConcurrentHashMap<String, String>();
	//图片文件夹
	private String rootPath = Environment.getExternalStorageDirectory().getAbsolutePath() 
			+ MainActivity.SEPERATOR + "WebViewDemo";
	
	private DAOHelper helper;
	//存放图片下载器信息
	private List<String> taskArray = new ArrayList<String>();
	
	
	@Override
	protected void onCreate(Bundle savedInstanceState) 
	{
		super.onCreate(savedInstanceState);
		setContentView(R.layout.webview_act_main);
		//数据库操作类
		helper = new DAOHelper(WebViewActicvity.this);
		
		start();
	}
	
	private void start()
	{
		wv_main = (WebView)findViewById(R.id.wv_main);
		
		wv_main.getSettings().setJavaScriptEnabled(true);
		wv_main.setWebViewClient(new WebViewClient());
		// 单列显示
		wv_main.getSettings().setLayoutAlgorithm(LayoutAlgorithm.SINGLE_COLUMN);
		// 添加JavaScript接口
		// "mylistner" 这个名字不能写错,
		// 因为在WebView加载的HTML中的JavaScript方法会回调mylistner里面的方法,所以两者名字要一致
		wv_main.addJavascriptInterface(new JavascriptInterface(WebViewActicvity.this), "mylistner");
		// 为了模拟向服务器请求数据,加载HTML, 我已提前写好一份,放在本地直接加载
		wv_main.loadUrl("file:///android_asset/wv_content.html");
	}
	
	
	private class JavascriptInterface 
	{
		private Context context;

		public JavascriptInterface(Context context) 
		{
			this.context = context;
		}
		
		//该方法被回调替换页面中的默认图片
		@android.webkit.JavascriptInterface
		public String replaceimg(String imgPosition , String imgUrl, String imgTagId)
		{
			if(!map.containsKey(imgUrl))
			{	//如果中介存储器MAP中存在该图片信息,就直接使用,不再去数据库查询
				String imgPath = helper.find(imgUrl);
				if(imgPath != null && new File(imgPath).exists())
				{
					map.put(imgUrl, imgPath);
					return imgPath;
				}
				else
				{	
					
					if(taskArray.indexOf(imgUrl) < 0)
					{	// 当图片链接不存在数据库中,同时也没有正在下载该链接的任务时, 就添加新的下载任务
						// 下载任务完成会自动替换
						taskArray.add(imgUrl);
						DownLoadTask task = new DownLoadTask(imgTagId, imgPosition, imgUrl);
						task.execute();
					}
					// 为了模拟默认图片的加载进度, 在这里返回另一张不一样的默认图片,
					// 具体应用中,可以根据需求将该处改为某些百分比之类的图片
					return "file:///android_asset/test.jpg";
				}
				
			}
			else
			{
				return map.get(imgUrl);
			}
		}
		
		
	}
	
	//图片下载器
	private class DownLoadTask extends AsyncTask<Void, Void, String>
	{
		String imageId; 		//标签id
		String imagePosition;	//图片数组位置标记
		String imgUrl;			//图片网络链接
		
		public DownLoadTask(String imageId, String imagePosition, String imgUrl)
		{
			this.imageId = imageId;
			this.imagePosition = imagePosition;
			this.imgUrl = imgUrl;
		}

		@Override
		protected String doInBackground(Void... params) 
		{
			try 
			{
				// 下载图片
				URL url = new URL(imgUrl);
				HttpURLConnection conn = (HttpURLConnection) url.openConnection();
				conn.setConnectTimeout(20 * 1000);  
				conn.setReadTimeout(20 * 1000);
		        conn.setRequestMethod("GET");  
		        conn.connect();
		        InputStream in = conn.getInputStream();
		        
		        byte[] myByte = readStream(in);
		        //压缩存储,有需要可以将bitmap放入别的缓存中,另作他用, 比如点击图片放大等等
		        Bitmap bitmap = BitmapFactory.decodeByteArray(myByte, 0, myByte.length);
		        
		        String fileName = Long.toString(System.currentTimeMillis()) + ".jpg";
		        File imgFile = new File(rootPath + MainActivity.SEPERATOR +fileName);
		        
		        BufferedOutputStream bos 
		        	= new BufferedOutputStream(new FileOutputStream(imgFile));  
		        bitmap.compress(Bitmap.CompressFormat.JPEG, 80, bos); 
		        
		        bos.flush();  
		        bos.close();
		        
		        return imgFile.getAbsolutePath();
		        
			} 
			catch (Exception e) 
			{
				e.printStackTrace();
			}
			return null;
		}
		
		@Override
		protected void onPostExecute(String imgPath)
		{
			super.onPostExecute(imgPath);
			if(imgPath != null)
			{
				//对页面调用js方法, 将默认图片替换成下载后的图片
				String url = 
						"javascript:(function(){" 
						+ "var img = document.getElementById(\""
						+ imageId
						+ "\");"
						+ "if(img !== null){"
						+ "img.src = \""
						+ imgPath
						+ "\"; }"
						+ "})()";
						
				wv_main.loadUrl(url);
				// 将将图片信息缓存进中介存储器
				map.put(imgUrl, imgPath);
				// 将图片信息缓存进数据库
				helper.save(imgUrl, imgPath);
			}
			else
			{
				Log.e("WebViewActicvity error", "DownLoadTask has a invalid imgPath...");
			}
			
		}
		
	}
	
	private byte[] readStream(InputStream inStream) throws Exception
	{  
        ByteArrayOutputStream outStream = new ByteArrayOutputStream();  
        byte[] buffer = new byte[2048];  
        int len = 0;  
        while( (len=inStream.read(buffer)) != -1){  
            outStream.write(buffer, 0, len);  
        }  
        outStream.close();  
        inStream.close();  
        return outStream.toByteArray();  
    }
	
	
	
	
	
}


里面用到的assets文件夹中的图片,可以用自己的图片替换. 关键的HTML模板内容,在这里要先感谢一位前端开发的大神, 我是在他基础上做了修改. 这是他的原文JavaScript实现 页面滚动图片加载 , 推荐读者先去看看原文, 理解里面的实现原理, 不懂得地方积极请搜索或者看书. 里面用到的DOM方法都是很有用的, 值得巩固学习! 


下面是我修改后的HTML源码, wv_content.html 放在assets文件夹 :

HTML中涉及的新闻文字和图片 均来自喜欢的 ifanr - 爱范儿网. 去星巴克,喝杯咖啡配科技  个人经常关注的科技新闻网之一, 感谢!  )

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"> 
<head> 
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 
<title>WebViewDemo - Alex Tam</title> 
<style> 
body{text-align:center;}
.list{margin-bottom:40px;}
</style> 
</head> 
<body> 
<div> 
    <div> 
        <h1>去星巴克,喝杯咖啡配科技</h1> 
        <span style="color:purple;">商业 |  陈 昊 | 3 小时前 (转载自ifanr爱范儿)</span><br /><br />
        <div id="content"> 
			星巴克绝对是全球科技媒体中亮相频率最高的咖啡店。
			<br/><br/>
			他们积极地在科技领域寻求合作,也是全球罕见地设置有首席数码官(Chief Digital Officer)一职的咖啡连锁企业,担任此职位的 Adam Brotman 在接受福布斯采访时曾经表示:“在连接线上和线下的时候,我们认识到互联网的线上资源能够让我们能更好地通过全新的互动,大规模地和客户建立可靠的联系。”
			<br/><br/>
			一家具有科技范儿的咖啡馆是怎么样的?
			<br/><br/>
				<div class="list"><img class="scrollLoading" id="img_1" xSrc="http://images.ifanr.cn/wp-content/uploads/2015/05/powermat-hands-on.jpg" src="file:///android_asset/nopic.gif"" style="background:url(file:///android_asset/nopic.gif") no-repeat center;" /><br />
				<h3>Powermat 无线充电<h3>
				</div>
			走进星巴克还带着自己的充电器和数据线到处找插座的日子很快就要到头了。在美国旧金山湾区的星巴克门店已经配备了 Powermat 无线充电系统。
			<br/><br/>
			只要将 Powermat 提供的充电环接到手机充电口上,再按照指示将充电环摆放到桌面上的特定位置,位于桌子下方的充电组件就会开始为手机进行无线充电。
			<br/><br/>
			早在 2012 年,星巴克就宣布支持 Powermat 背后的无线供电标准联盟 Power Matter Alliance (PMA),助其推广该无线充电技术。
			<br/><br/>
			而他们的对手 WPA 也非常强劲, 因为 WPA 是 Qi 无线充电标准的推动者——包括三星、LG、诺基亚等大厂商都支持这个标准。
			<br/><br/>
			其实这也不是星巴克第一次在标准大战中站队了,早在 2001 年,星巴克就已经在科技界发挥了自己的力量,它和苹果纷纷站在的 Wi-Fi 标准这边,助其击败 Home RF,成为当今家喻户晓的无线网络标准。
			<br/><br/><br/>
				<div class="list"><img class="scrollLoading" id="img_2" xSrc="http://images.ifanr.cn/wp-content/uploads/2015/05/starbucks_spotify.jpg" src="file:///android_asset/nopic.gif"" style="background:url(file:///android_asset/nopic.gif) no-repeat center;" /><br />
				<h3>Spotify 在线音乐<h3>
				</div>
			<br/><br/><br/>
				<div class="list"><img class="scrollLoading" id="img_3" xSrc="http://images.ifanr.cn/wp-content/uploads/2015/05/starbucks-app.jpg" src="file:///android_asset/nopic.gif" style="background:url(file:///android_asset/nopic.gif) no-repeat center;" /><br />
				<h3>强大的星巴克官方 App<h3>
				<div class="list"><img class="scrollLoading" id="img_4" xSrc="http://images.ifanr.cn/wp-content/uploads/2015/05/starbucks_order_and_pay.jpg" src="file:///android_asset/nopic.gif" style="background:url(file:///android_asset/nopic.gif) no-repeat center;" /><br />
				<br/>
				<div class="list"><img class="scrollLoading" id="img_5" xSrc="http://images.ifanr.cn/wp-content/uploads/2015/05/starbucks-postmates.jpg" src="file:///android_asset/nopic.gif" style="background:url(file:///android_asset/nopic.gif) no-repeat center;" /><br />
				<br/>
				</div>
			<h5>星巴克的官方 App 还整合了移动支付功能,而且非常受欢迎。根据星巴克2015 年第一财季的财报,在美国市场,他们平均每周会有 700 万笔交易通过手机完成,占交易总数 16%。</h5>
			<br/><br/>
			一个咖啡连锁店的 App,居然变成了全美最受欢迎的移动支付应用之一。
			<br/><br/><br/>
			<a href="http://www.ifanr.com/522993">原文链接</a>
			<br/><br/>
        </div> 
    </div> 
</div> 
<script type="text/javascript">
    var scrollLoad = (function (options) {
        var defaults = (arguments.length == 0) ? { src: 'xSrc', time: 1000} : { src: options.src || 'xSrc', time: options.time ||1000};
        var camelize = function (s) {
            return s.replace(/-(\w)/g, function (strMatch, p1) {
                return p1.toUpperCase(); 
            });
        };
        this.getStyle = function (element, property) {
            if (arguments.length != 2) return false;
			
            var value = element.style[camelize(property)];
            if (!value) {
                if (document.defaultView && document.defaultView.getComputedStyle) {
                    var css = document.defaultView.getComputedStyle(element, null);
                    value = css ? css.getPropertyValue(property) : null;
                } else if (element.currentStyle) {
                    value = element.currentStyle[camelize(property)];
                }
            }
            return value == 'auto' ? '' : value;
        };
        var _init = function () {
            var offsetPage = window.pageYOffset ? window.pageYOffset : window.document.documentElement.scrollTop,
                offsetWindow = offsetPage + Number(window.innerHeight ? window.innerHeight : document.documentElement.clientHeight),
                docImg = document.images,
                _len = docImg.length;
            if (!_len) return false;
            for (var i = 0; i < _len; i++) {
                var attrSrc = docImg[i].getAttribute(defaults.src),
                    o = docImg[i], tag = o.nodeName.toLowerCase(), imgId = o.id;;
                if (o) {
                    postPage = o.getBoundingClientRect().top + window.document.documentElement.scrollTop + window.document.body.scrollTop; postWindow = postPage + Number(this.getStyle(o, 'height').replace('px', ''));
                    if ((postPage > offsetPage && postPage < offsetWindow) || (postWindow > offsetPage && postWindow < offsetWindow)) {
                        if (tag === "img" && attrSrc !== null) {
                            o.src = window.mylistner.replaceimg(i,attrSrc,imgId);
                        }
                        o = null;
                    }
                }
            };
            window.onscroll = function () {
                setTimeout(function () {
                    _init();
                }, defaults.time);
            }
        };
        return _init();
    });
    scrollLoad();
</script> 
</body> 
</html>


该示例实现, 当进入WebView的页面是, 首次加载HTML会显示文字和默认的图片, 然后显示另一张加载中的图片(比如代表进度),  同时需要网络下载图片, 当图片下载完成, 无论页面滚动不滚动都会替换旧的图片. 这时图片的信息会同时放入数据库和中介存储MAP中, 如果MAP存在图片信息就不去数据库查找了(能在应用内获取的数据, 尽量不去数据库读取. ) 当关闭页面, 再次进入, 即使关闭网络, 也能很快的显示原图. 整个效果就是这样吧. 


另外, 上面除了本地代码, HTML中涉及的JavaScript代码部分也是实现的关键. 该 js 是每次滚动页面都会去检测img标签的信息, 这样每次滚动都有了检测图片加载进度的机会. 同时将图片替换的js 接口放在本地去加载调用. 结合两者, 其实可以增加实现某张图片的加载进度百分比, 当然每个百分比其实是对应各自的图片, 只需要在不同进度的时候返回代表某个百分比的图片即可. 例子中, 将缓存实现的重点放在了图片, 重在阐释这次模仿ZAKER这类应用缓存新闻的实现原理, 所以如何将文字保存在数据库就没实现了, 相信大家都能自己做的哈. 希望这次的例子, 能帮助加深我们大家对新闻应用内容加载方式的理解, 从而做出更好的应用.


就写到这了, 晚安.


源码下载

1楼CsL664867596前天 10:53
好文,谢谢分享