相信去年圣诞节打开过手机淘宝的童鞋都会对当时的特效记忆犹新吧:全屏飘雪,旁边还有个小雪人来控制八音盒背景音乐的播放,让人有种身临其境的感觉,甚至忍不住想狠狠购物了呢(误),大概就是下面这个样子滴:
嗯,确实很炫,那么我们一步步去分析是如何实现的:
一、实现下雪的 View
首先,最上面一层的全屏雪花极有可能是一个顶层的View,而这个View是通过动态加载去控制显示的(不更新淘宝也能看到这个效果)。那么我们先得实现雪花效果的 View,人生苦短,拿来就用。打开 gank.io,搜索"雪花":
看样子第7个库就是我们想要的了,点进源码,直接 download 不解释,记得 star 一个支持作者。那么现在我们的项目中就有一个完整的下雪效果 View 了。
二、实现雪人播放器 View
这个一张雪人图片+一个按钮即可实现,就不多解释了。接下来需要一段圣诞节音频,直接进行在线音频播放无疑是节省空间的好方案。『我的滑板鞋』烘托出的寂寞而甜蜜的氛围无疑是最适合圣诞节的,因此我们得到了『神曲』URL 一枚:
http://cdn.ifancc.com/TomaToDo/bgms/my_hbx.mp3
接下来要找一个小雪人的图片当作播放器的背景,那么阿姆斯特朗...不对,是这个:
嗯,相当可爱喜庆。那么播放器核心代码如下:
- package com.kot32.christmasview.player;
- import android.content.Context;
- import android.media.AudioManager;
- import android.media.MediaPlayer;
- import android.util.AttributeSet;
- import android.view.View;
- import android.widget.Toast;
- import com.kot32.christmasview.R;
- import java.io.IOException;
- /**
- * Created by kot32 on 16/12/8.
- */
- public class MyPlayer extends View {
- public MediaPlayer mediaPlayer;
- public MyPlayer(Context context) {
- super(context);
- init();
- }
- public MyPlayer(Context context, AttributeSet attrs) {
- super(context, attrs);
- init();
- }
- private void init() {
- setBackgroundResource(R.drawable.pig);
- mediaPlayer = new MediaPlayer();
- mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
- playUrl("http://172.20.248.106/IXC5b415fcacfc3c439e25a3e74533d2239/TomaToDo/bgms/my_hbx.mp3");
- Toast.makeText(getContext(), "开始播放", Toast.LENGTH_SHORT).show();
- setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View v) {
- if (!mediaPlayer.isPlaying()) {
- mediaPlayer.start();
- Toast.makeText(getContext(), "继续播放", Toast.LENGTH_SHORT).show();
- } else {
- mediaPlayer.pause();
- Toast.makeText(getContext(), "暂停播放", Toast.LENGTH_SHORT).show();
- }
- }
- });
- }
- public void playUrl(String videoUrl) {
- try {
- mediaPlayer.reset();
- mediaPlayer.setDataSource(videoUrl);
- mediaPlayer.prepare();//prepare之后自动播放
- mediaPlayer.start();
- } catch (IllegalArgumentException e) {
- e.printStackTrace();
- } catch (IllegalStateException e) {
- e.printStackTrace();
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- @Override
- protected void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- try {
- media
三、动态加载思路
上面基本实现了在本地的雪花以及播放音乐效果,那么在不更新主程序的情况下,如何将这两个View动态加载到主程序当中去呢?
首先我们明白,Android 的DexClassloader 是拥有加载任意APK 中任意类的能力的,只是有以下限制:
- 加载出的Activity 由于不在宿主 Manifest 文件中声明,因此框架无法找到并初始化这个Activity。
- 加载出的Activity 不具备生命周期,理由同上。
- 加载出的类的Resource 文件id 会和主程序混淆在一起。
由于我们只是加载View,并不是加载整个Activity,所以前两个问题并不会遇到,而第三个问题可以想办法解决掉。
在主程序中我们也要做这三件事:
- 把能够装载View的ViewGroup 的空位留出来
- 去获取更新的patch包
- 把View 从apk包中加载出来之后,放进留好的ViewGroup 中。这样一来,不仅是圣诞节,在之后的各种活动上都可以在线去加载活动的View。
四、开始加载
在加载View 之前,首先要意识到这个View 是引用了图片资源的(小猪图片),因此我们要解决资源问题:
- private void initResource() {
- Resources resources = getContext().getResources();
- try {
- AssetManager newManager = AssetManager.class.newInstance();
- Method addAssetPath = newManager.getClass().getMethod("addAssetPath", String.class);
- addAssetPath.invoke(newManager, DynamicViewManager.getInstance().getUpdateFileFullPath());
- Resources newResources = new Resources(newManager,
- resources.getDisplayMetrics(), resources.getConfiguration());
- Reflect.onObject(getContext()).set("mResources", newResources);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
上面代码的作用是:把添加了外部更新包路径的资源管理器赋值给了App原来的资源管理器,也就是说现在可以在宿主中访问插件资源了。
核心加载代码如下:
- DexClassLoader classLoader = new DexClassLoader(apkFile.getAbsolutePath()
- , "dex_out_put_dir"
- , null
- , getClass().getClassLoader());
- Class newViewClazz = classLoader.loadClass("view's package name");
- Constructor con = newViewClazz.getConstructor(Context.class);
- //first use Activity's Resource lie to View
- if (dynamicView == null) {
- dynamicView = (View) con.newInstance(getContext());
- }
- //Replace the View's mResources and recovery the Activity's avoid disorder of Resources
- Reflect.onObject(getContext()).set("mResources", null);
- getContext().getResources();
- RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(DisplayUtil.dip2px(getContext(), viewInfo.layoutParams.width),
- DisplayUtil.dip2px(getContext(), viewInfo.layoutParams.height));
- layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE);
- addView(dynamicView, layoutParams);
中间对 mResources 的操作的作用是:将宿主的Activity 的mResources 重置,避免在Activity 中使用资源时和插件冲突。
然而机智的我已经把更新包下载、版本管理、动态加载都封装好了,所以正确的加载方式是:
引用它:https://github.com/kot32go/dynamic-load-view
然后:
1.宿主声明:
- <RelativeLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@drawable/tb_bg"
- >
- <com.kot32.dynamicloadviewlibrary.core.DynamicViewGroup
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- app:uuid="activity_frame">
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="原始页面"
- />
- </com.kot32.dynamicloadviewlibrary.core.DynamicViewGroup>
- <com.kot32.dynamicloadviewlibrary.core.DynamicViewGroup
- android:layout_width="60dp"
- android:layout_height="60dp"
- android:layout_alignParentRight="true"
- android:layout_centerVertical="true"
- app:uuid="activity_player">
- </com.kot32.dynamicloadviewlibrary.core.DynamicViewGroup>
- </RelativeLayout>
以上声明了主界面的布局,当然,在动态加载之前除了原有的"原始页面"TextView,是不会有任何其他东西的,也就是圣诞节来临之前的程序。注意:uuid 会和在线包相匹配。
2.打插件包
其实就是把之前包含了我们所写的两个View(雪花和雪人)的程序打包成apk。可以不签名。
3.把插件包放到服务器
在服务器返回的JSON中声明插件包地址和动态View 的一些参数,这里的演示程序请求地址为:
http://tomatodo.ifancc.com/php/dynamicView.php
返回值为:
- {
- "version": 54,
- "downLoadPath": "http://obfgb7oet.bkt.clouddn.com/patch106.apk",
- "fileName": "patch106.apk",
- "viewInfo": [
- {
- "packageName": "com.kot32.testdynamicviewproject.snow.widgets.SnowingView",
- "uuid": "activity_frame",
- "layoutParams": {
- "width": -1,
- "height": -1
- } },
- {
- "packageName": "com.kot32.testdynamicviewproject.player.MyPlayer",
- "uuid": "activity_player",
- "layoutParams": {
- "width": -1,
- "height": -1
- } }
- ]}
我们声明了这次在线包的版本,每个View 的包名和布局参数, 以及最重要的 和宿主程序中声明对齐的uuid。
另外,Dynamic-load-view 能够动态加载外部apk中的View以及资源,能够热修复线上View,以及模块化更新。
屏幕截图
特点
- 插件程序完全独立于宿主。
- 以 View作为模块进行模块化开发更新。
- 你也可以把View 铺满整个Activity,相当于更新Activity。
- 副作用小,没有加载Activity 带来的生命周期等问题。
- 兼容性好。Android 4.0~6.0 都没有问题。
- 简单。核心代码不超过400行。可以自行下载源码,修改更新规则。
如何使用
- 下载库,并作为library 引用。
- 需要在宿主程序的Application 的onCreat 中初始化,代码如下:.
- DynamicViewConfig config = new DynamicViewConfig.Builder()
- .context(this)
- .getUpdateInfoApi("http://vpscn.ifancc.com/php/dynamicView.php")
- .build();
- DynamicViewManager.getInstance(config).init();
getUpdateInfoApi 这个方法需要传入一个API地址,这个API地址给客户端提供更新的信息. 在上面的地址中,服务器返回了下面这样的JSON 串:
- {
- "version": 39,
- "downLoadPath": "http://obfgb7oet.bkt.clouddn.com/patch101.apk",
- "fileName": "patch101.apk",
- "viewInfo": [
- {
- "packageName": "com.kot32.testdynamicviewproject.MyButton",
- "uuid": "test",
- "layoutParams": {
- "width": 100,
- "height": 100
- }
- },
- {
- "packageName": "com.kot32.testdynamicviewproject.MyButton1",
- "uuid": "test_activity",
- "layoutParams": {
- "width": -1,
- "height": -1
- }
- }
- ]
- }
上面的JSON 串定义了本次更新的版本以及更新包的地址,并且提供了对每个View 的详细更新信息。
packageName :插件APK 中View 的完整包名.
uuid : 和宿主程序中待更新 View 相同的 UUID.
layoutParams:布局参数.
你也可以自己修改服务器需要提供的参数,更改com.kot32.dynamicloadviewlibrary.model 包中的模型类即可。
- 待更新的View 需要xml 布局文件中如下声明.注意uuid 属性必须赋值。更新时会匹配uuid 相同的View。
- <com.kot32.dynamicloadviewlibrary.core.DynamicViewGroup
- android:id="@+id/dv"
- android:layout_width="200dp"
- android:layout_height="200dp"
- app:uuid="test"
- android:layout_centerInParent="true">
- <!--default view -->
- <ImageView
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:src="@mipmap/ic_launcher" />
- </com.kot32.dynamicloadviewlibrary.core.DynamicViewGroup>
对于插件程序,只需要定义View 就好了,之后直接打成APK 包即可。
更多详细信息,请直接下载示例源码查看,源码不多,也很好理解。
缺陷
- 现在可以加载插件程序中的string和drawable 资源,但是style.xml 和 dimens.xml 的加载还存在一些问题。
- 插件程序中的资源文件的名字最好不要和主程序中重复。
- 在插件中访问资源请使用:getContext().getResources()