總體概覽
最近項目出現了一個新的需求,需要實現類似微信的表情的鬥圖功能。由於我們是一家基於互聯網+裝修的公司,爲了給業主創造一個好的印象和營造開工、完工的美好氣象,這個需求我們還是默默的接了下來,雖然我們知道坑那是多得一比。下面我們就簡單說明一下我們的方案。
已經實現的功能大概是這樣的:
大致實現的功能如下:
- 服務器動態配置鬥圖的類型和風格。因爲每家公司的主題和風格樣式都是不一樣的,這裏需要後臺配置,服務器有什麼圖,客戶端才顯示什麼圖。特別的節日氣息我們可以增刪一些圖片。
- 鬥圖可以是jpg、png和gif圖片,大小可是控制,但需要提供圖片的尺寸(實際的寬和高)。
- 客戶端按服務器端動態改變鬥圖樣式。
- 客戶端可以播放jpg、png和gif動圖。
服務器端準備
我們的思路大概如下圖所示:
大致的邏輯如下:
- 客戶端首先請求服務器鬥圖md5值,這個鬥圖md5是根據鬥圖表情文件計算出來的。之所以要請求這個這個md5值,有以下幾個目的:如果修改了鬥圖表情包裏面的內容,像你新增了某些表情包,刪除了某些表情包,那麼表情包文件的md5值就會變化,我們客戶端通過比較如果本地的md5值和服務器的md5值不一樣,我們就知道了表情包更新了,就需要重新下載新的表情包了,比較簡單,同時也很快捷。
- 通過比較新舊md5值,我們就可以判斷是否需要下載服務器的表情包了。因爲這個表情包一般都比較大,所以如果本地的表情包存在,而且沒有損壞,能夠加載出表情的內容,那我們直接使用本地的,不用直接請求就可以了。
- 如果本地不存在,我們可以看出,先是下載我們的表情包文件,此時的文件我們定義爲json格式的,可以通過Gson或者FastJson等第三方工具直接構建我們的JavaBean對象,也是十分快的。下載完表情包文件之後,先存儲,然後更新本的md5值,最後構建成我們新的java對象,供雲信調用。
客戶端
這裏主要是大致講一下Android的具體實現,IOS客戶端大致上也是類似的相同模式。
自定義消息
首先我們使用的是雲信,官網,豬場最近名聲不怎麼好,也沒什麼辦法,主要是這個項目兩年前就已經用雲信了,綜合起來看,效果還是可以的。話不多扯,我們也是集成了雲信開源的第三方組件庫UIKit, 這個bug還是蠻多的,這裏不說了,各位集成了的,都希望自求多福了。
爲了實現可以鬥圖功能,目前雲信的幾個消息類型顯然是不能滿足的。我們首先需要明確的是:
- 鬥圖其實就是顯示一張圖片,這張圖片的url是我們服務器下發的,它可能是jpg、png或者gif。
- 我們需要合適的組件可以顯示jpg、png和gif等不同圖片類型。
- 在RecyclerView中作爲自定義類型,需要滑動流暢,不能卡頓,有停頓的感覺。
我們假設是很熟悉雲信的架構,如果不熟悉,可以先聽我瞎比比一下也是可以的。
首先我們自定義一下表情包的消息。先展示一下消息體:
{
"type" : 29,
"data" : {
"url" : "http://www.shigong.com/32.gif",
"type" : "0",
"name" : "鼓掌",
"img_width": 204,
"img_height" : 304,
"extend" : null
}
}
大致含義如下:
字段 | 屬性 |
---|---|
type | 這個是雲信自定義消息分類值,你可以自己定義,因爲我們自定義的消息非常多, 所以這個達到了29 |
url | 資源的url地址 |
type | 內部type值,表明是表情包的類別,比如是jpg、png還是gif等格式圖片 |
name | 定義表情包的意義,每一個表情包都有自己的靈魂和意義 |
img_width | url資源的寬度,這裏是爲了解決在Recylerview中快速滑動時不至於過於卡頓,我們通過明確控制表情包的大小直接寫死佈局的大小,避免使用wrap_content造成測量時耗時問題 |
img_height | url資源的高度 |
extend | 暫時無意義,用於以後可能的擴展使用 |
大致自定義表情結構類如下:
public class CustomImageAttachment extends CustomAttachment {
private static final String CUSTOM_URL = "url";
private static final String CUSTOM_TYPE = "type";
private static final String CUSTOM_NAME = "name";
private static final String CUSTOM_IMG_WIDTH = "img_width" ;
private static final String CUSTOM_IMG_HEIGHT = "img_height";
private static final String CUSTOM_EXTEND = "extend";
//自定義圖片url
private String url ;
//類型
public String type ;
//描述
public String name ;
//自定義表情寬度
public String width;
//自定義表情高度
public String height;
public CustomImageAttachment() {
super(CustomAttachmentType.CUSTOM_IMAGE);
}
@Override
protected JSONObject packData() {
JSONObject jsonObject = new JSONObject();
jsonObject.put(CUSTOM_URL, url);
jsonObject.put(CUSTOM_NAME, name);
jsonObject.put(CUSTOM_TYPE, type);
jsonObject.put(CUSTOM_IMG_WIDTH,width);
jsonObject.put(CUSTOM_IMG_HEIGHT,height);
jsonObject.put(CUSTOM_EXTEND,null);
return jsonObject;
}
@Override
protected void parseData(JSONObject data) {
url = data.getString(CUSTOM_URL);
name = data.getString(CUSTOM_NAME);
type = data.getString(CUSTOM_TYPE);
width = data.getString(CUSTOM_IMG_WIDTH);
height = data.getString(CUSTOM_IMG_HEIGHT);
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getCustomType() {
return type;
}
public void setCustomType(String type) {
this.type = type;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public void setWidth(String width) {
this.width = width;
}
public void setHeight(String height) {
this.height = height;
}
public int getIntWidth() {
if(TextUtils.isEmpty(width)) return 0;
return BaseStringUtils.safe2Int(width);
}
public int getIntHeight() {
if(TextUtils.isEmpty(height)) return 0;
return BaseStringUtils.safe2Int(height);
}
}
實體類好了,我們現在需要想到佈局了。剛開始想到了很簡單,只使用一個ImageView就可以了,比如這樣的:
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/id_iv_image"
/>
同時也google到了Glide也是可以加載gif圖片的,同時Glide的天然屬性是可以加載jpg、png的屬性圖片的,所以關鍵代碼如下:
CustomImageAttachment attachment = (CustomImageAttachment) message.getAttachment();
if(attachment.getCustomType() == 0) { //gif
Glide.load(attachment.getUrl()).asGif().info(id_iv_image);
}else { //png jpg and other format
Glide.load(attachment.getUrl()).info(id_iv_image);
}
//...code ignore
大致就這麼多?我覺得應該很簡單了,直接擼就完事,但是看到一張圖之後:
有些奇怪的是,使用Glide加載出gif圖片之後,左邊的圖片發現背景全變黑色了,但是我們實際的gif圖片卻沒有黑色背景,搞得我百思不得其解,查了很多相關資料,也沒能搞出個完美的解釋(也許我google的方式不對,如果你有更好的方式,請留言告訴我,最終UI小姐姐告訴我,這張圖的背景是透明的,但是是透明的爲啥Glide加載就變成了黑色的呢?不太明白,要改!!!暫定。
在測試使用一段時間,發現機子內存有些喫不消了,有些低端機型直接報出OOM了,原因是我們的後臺有很多gif圖片都是五六百KB的,還有一個是2M多,這感覺有些喫不消了啊,可能Glide本身對Gif圖片的支持並沒有那麼好吧。如果後臺沒有控制圖片的大小,任由管理人員CRUD,我擔心這APP這個功能就比較雞肋了。
基於以上兩個原因,我的想法是既然Glide會更改Gif圖片的屬性(會使背景變黑,這個雖然可以通過UI小姐姐切圖更改,但總覺得不是長久之計),Glide對Gif圖片的支持沒有想象中那麼優秀,那麼我們可不可以使用更加專業的Gif插件支持呢?
答案是有!
在github上找到了android-gif-drawable,但是坑爹的事情又來:
它支持文件,支持Bitmap,支持數組,支持Uri,就是不支持URL!!!在觀察微信客戶端 但是的確是這麼個意思呀!微信在發送動圖的時候,對方好像也是先要下載到本地,下載完成之後纔開始播放的。那我們是不是也需要這麼做呢?現將url資源下載到本地,然後下載,下載完成之後再播放呢?
說幹就幹吧,不過又需要重新面對各種問題了:
1.RecyclerView中同時會下載好多個Item,如果每個Item都是需要去下載的,伴隨中滑動,會不會有重複下載的?
2.加入我們將一個URL資源下載成功?怎麼回調到RecyclerView中,告知哪一個Item下載成功,然後重新更新界面?
3.如果已經下載過一次了,但是下載資源失敗,我們應該採用什麼樣的方式去兜底,此時該如何顯示該圖片呢?
4.如果下載會持續一段時間,那我們的PlaceHolder應該怎麼顯示呢?
這只是我當前在開發和測試中遇到的一些問題,在解決了這些問題之後,大致可以進行播放了。
這裏先畫一張草圖:
現在,最主要的目標是 該如何設計我們的DownLoadManager,需要滿足的條件是:
- 針對同一個url需要自動過濾,相同的下載資源直接pass。
- 每個下載任務需要有一個TaskTag,通過下載TaskTag可以知道是哪一個url下發的任務,這樣就能資源混亂。
- 多線程下下載,怎麼才能保證回調的有序性?比如你的三個線程同時完成了下載任務,回調到RecyclerView中,那樣會同時調用adapter.notifyDataChanged(),這樣也是不怎麼科學的?
考慮到只是在一個RecyclerView中的數據,在第三條保證回調的有序性時,此時使用了一個SerialQueue序列,將任務線性排列執行,雖然犧牲了多線程的優勢,但是爲在Adapter執行notifyDataChanged()刷新數據不混亂,這種犧牲還是可以接受的。
這裏使用了 第三方多線程下載框架:okdownload
基本下載框架如下:
/**
* created by microHx
* <p>
* 縱然萬劫不復,縱然相思入骨,我依然待你眉眼如初,歲月如故。
* <p>
* date : 2019-07-09
* <p>
* version :
* <p>
* desc : Emoji下載管理工具類
*/
public class EmojiDownloadManager {
/**
* 下載單例模式
*/
private static EmojiDownloadManager manager = new EmojiDownloadManager();
private EmojiDownloadManager(){}
public static EmojiDownloadManager getInstance(){ return manager; }
/**
* 下載監聽器集合
*/
private List<OnEmojiDownloadListener> mListContainer = new ArrayList<>();
/**
* 下載的url容器 避免重複下載
*/
private Set<String> mUUIdContainer = new HashSet<>();
/**
* 下載序列 execute task one by one
**/
private DownloadSerialQueue mSerialQueue = new DownloadSerialQueue(new DownloadListener2() {
@Override
public void taskStart(@NonNull DownloadTask task) {
BaseLog.i("taskStart : " + Thread.currentThread().getName() + "," + task.getUrl() + "," + task.getTag());
}
@Override
public void taskEnd(@NonNull DownloadTask task, @NonNull EndCause cause, @Nullable Exception realCause) {
BaseLog.i("taskEnd:" + cause.name() + "," + realCause + "," + task.getTag() + "," + Thread.currentThread().getName());
String uuid = String.valueOf(task.getTag());
mUUIdContainer.remove(uuid);
if(BaseCommonUtils.checkCollection(mListContainer)){
for(OnEmojiDownloadListener listener : mListContainer){
if(null != listener) {
listener.onDownloadFinished(uuid, cause == EndCause.COMPLETED);
}
}
}
}
});
/**
*
* 下載管理
*
* @param uuid 唯一下載標識
* @param url 下載目標URL
* @param parentPath 文件目錄地址
* @param fileName 存儲文件名
*/
public void download(String uuid, String url , String parentPath , String fileName) {
if(mUUIdContainer.contains(uuid)) return;
mUUIdContainer.add(uuid);
DownloadTask downloadTask = new DownloadTask.Builder(url, parentPath, fileName).
setPassIfAlreadyCompleted(true).
setMinIntervalMillisCallbackProcess(5000).
setWifiRequired(false).build();
downloadTask.setTag(uuid);
mSerialQueue.enqueue(downloadTask);
}
/**
* 註冊下載監聽器
* @param listener 監聽器
* @param register 是否註冊 true進行註冊 false取消註冊
*/
public void registerEmojiDownloadListener(OnEmojiDownloadListener listener, boolean register){
if(register){
if(!mListContainer.contains(listener)){
mListContainer.add(listener);
}
}else{
mListContainer.remove(listener);
}
}
}
OnEmojiDownloadListener 回調比較簡單:
/**
* created by microHx
* <p>
* 縱然萬劫不復,縱然相思入骨,我依然待你眉眼如初,歲月如故。
* <p>
* date : 2019-11-29
* <p>
* version :
* <p>
* desc :
*/
public interface OnEmojiDownloadListener {
/**
* 文件下載完成 回調
* @param uuid 文件唯一uuid
* @param result 下載結果 成功爲true 失敗爲false
*/
void onDownloadFinished(String uuid, boolean result);
}
下載邏輯寫好了,我們最重要的自定義ViewHolder核心代碼也就貼一下:
/**
* created by microHx
* <p>
* 縱然萬劫不復,縱然相思入骨,我依然待你眉眼如初,歲月如故。
* <p>
* date : 2019-11-27
* <p>
* version :
* <p>
* desc :
*/
public class CustomImageViewHolder extends MsgViewHolderBase {
//最大的圖片大小
private static final int MAX_IMAGE_WIDTH = (int) (ScreenUtil.screenWidth * 0.45f);
//最小自圖片大小
private static final int MIN_IMAGE_WIDTH = (int) (ScreenUtil.screenWidth * 0.15f);
//需要加載的imageView
private GifImageView mImageView;
//下載等待pb
private ProgressBar mProgressbar;
public CustomImageViewHolder(BaseMultiItemFetchLoadAdapter adapter) {
super(adapter);
}
@Override
protected int getContentResId() {
return R.layout.item_custom_image_layout;
}
@Override
protected void inflateContentView() {
mImageView = findViewById(R.id.id_custom_image);
mProgressbar = findViewById(R.id.id_pb);
}
@Override
protected void bindContentView() {
CustomImageAttachment attachment = (CustomImageAttachment) message.getAttachment();
if(null != attachment){
String url = attachment.getUrl();
int targetWidth = attachment.getIntWidth();
int targetHeight = attachment.getIntHeight();
// 如果我們的目標寬度和高度同時存在
// 直接設置目標的寬度和高度
// 此時佈局很省時
if(targetHeight > 0 && targetWidth > 0){
if(targetWidth > MAX_IMAGE_WIDTH) {
targetWidth = MAX_IMAGE_WIDTH;
targetHeight = MAX_IMAGE_WIDTH * targetHeight / targetWidth;
}
if(targetWidth < MIN_IMAGE_WIDTH){
targetWidth = MIN_IMAGE_WIDTH;
targetHeight = MIN_IMAGE_WIDTH * targetHeight / targetWidth;
}
setLayoutParams(targetWidth,targetHeight, mImageView);
}
String fileName = MD5.getStringMD5("." + url);
File localFile = new File(SystemFileUtils.getGlobalDataPath(), fileName);
// 如果本msg已經被下載過了
if(IMessageManager.msgHasDownload(message)){
//如果本地文件存在 且文件是 圖片文件
if(BaseIOUtils.fileExist(localFile) && BaseIOUtils.fileIsImage(localFile)){
mProgressbar.setVisibility(View.GONE);
mImageView.setImageURI(Uri.fromFile(localFile));
}else{
mProgressbar.setVisibility(View.GONE);
//文件已經下載過了 但是文件損壞了 不見了 此時我們就使用 兜底模式 使用Glide加載
BaseImageLoader.loadWithPlaceHolder(mImageView, url);
}
}else{
//如果本地文件存在 且文件是 圖片文件
if(BaseIOUtils.fileExist(localFile) && BaseIOUtils.fileIsImage(localFile)){
mProgressbar.setVisibility(View.GONE);
mImageView.setImageURI(Uri.fromFile(localFile));
}else {
mProgressbar.setVisibility(View.VISIBLE);
mImageView.setImageResource(R.drawable.ic_default_icon);
IMessageManager.clearDownloadTag(message);
//開啓線程去下載url
EmojiDownloadManager.getInstance().download(message.getUuid(),url,SystemFileUtils.getGlobalDataPath(),fileName);
}
}
}
}
@Override
protected int leftBackground() {
return 0;
}
@Override
protected int rightBackground() {
return 0;
}
}
大致就這麼多了,寫得比較邏輯,如果有什麼問題,請各位留言。