SDK开发相关整理

一直感觉sdk很难,亲身接触之后才知道,在安卓端就相当于开发一个库,为其他App提供一些额外功能。但也有很多与应用开发的不同点和注意事项,还是要总结一下。这是一个游戏sdk,类似九游那种,给游戏提供登录/注册、充值功能,给用户提供一个悬浮窗管理个人账户。

开始构建

新建一个空项目之后,可以直接按照正常的开发流程来做,直接分包,写代码。最后,把build.gradle中第一行配置修改一下即可:

1
2
apply plugin: 'com.android.application'  -->  apply plugin: 'com.android.library'
//平时开发应用,生成apk包,sdk相当于开发一个库,用右边的gradle插件,编译时生成aar包

但是似乎不太规范,并且如果你想测试,要么就新建空项目导aar包或者module,要么就直接项目中建Activity测试;但前者你每次修改代码都要重新导文件,后者要把代码在上面的两个插件之间来回调换,测试时改app,发布时改lib,都很麻烦。

较好的做法

新建项目后再新建一个空module,在module中进行开发,空项目本身当成测试环境来用。这样添加了依赖之后,两边都随时修改随时测试,gradle是个好东西,要好好利用起来。

基本框架

  • 分包:小项目,个人感觉,以职能分包就好了。包内根目录放一个入口类,一般称为XXSDK或者XXSDKManager即可,单例模式给用户即插即用;activity包放各页面活动,adapter放各列表的适配器,bean存放实体类,callback存放回调接口,constant下是常量、状态码、消息接口,db写本地数据库操作(自动登录操作),floatwindow感觉悬浮窗比较有标志性单独存放了一个包,然后初始化、登录/注册、支付分别按照流程分了一个包,最后两个是util和view,基本每个项目都有的工具和自定义view。
  • 资源文件:跟app开发一样,动画、drawable、layout、value都是要有的,为了尽量保证提供的包体积小,图片选择只提供xxhdpi分辨率,覆盖率较广。AndroidManifest.xml文件,各活动和服务的注册以及参数都写到测试模块中,符合接入环境。
  • R.资源id问题:不像jar包那样只包含逻辑src代码,这样的库生成aar会包含资源文件,由于是外接给其他app,这些资源文件中定义的id无法像正常开发一样直接用R.id.xxx引入,会有文件冲突。findviewbyid是安卓开发中需要写很多的语句,这个时候肯定要想到butterknife了,但跟普通app中添加的依赖又不相同,JW大神文档中的接入方式也被反映在module中使用存在一些问题,他本人也好像没做出回复,可能也暂时没有找到完美的解决办法吧;并且,如果接入方的项目引入了同样的包,还要处理依赖冲突,所以决定还是找其他解决办法。

下面就是为了解决这个问题,在lib中添加的第一个工具类了,这据说是tim团队在处理这个问题时发出来的解决方案,不得不承认,自己可能还是没有动脑去想,因为并不难,原理是反射,通过id名称获取id值。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class MResource {

public static int getIdByName(Context context, String className, String name) {
//通过上下文,获取包名
String packageName = context.getPackageName();
Class r;
int id = 0;

try {
//通过包名拿到对应的R文件的class
r = Class.forName(packageName + ".R");
Class[] classes = r.getClasses();
Class desireClass = null;

for (Class aClass : classes) {
//找到对应的资源类型,id、layout、drawable等
if (aClass.getName().split("\\$")[1].equals(className)) {
desireClass = aClass;
break;
}
}

if (desireClass != null) {
//再找到对应类型下该name资源id
id = desireClass.getField(name).getInt(desireClass);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (IllegalArgumentException e) {
e.printStackTrace();
} catch (SecurityException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
return id;
}
}

常用工具类

一些基本每个项目都能用到的工具类,先拿过来,防止后面找,磨刀不误砍柴工。

日志工具

综合了我看到过的各种日志打印类的常用功能和优点,注释都写的很清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* 日志工具类
* 仅在测试环境打印日志,并可以在日志中获取详细信息。
*/
public class LogUtil {

private static String TAG = "yxj_sdk";

public static void setTAG(String TAG) {
LogUtil.TAG = TAG;
}

private LogUtil() {
}

/**
* 根据打包类型判断是否开启日志打印
*/
private static boolean isDebuggable() {
return BuildConfig.DEBUG;
}

/**
* 获取日志详细信息,包括类名、方法名、行号等,方便定位
*/
private static String getDetail() {
StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
if (stackTraceElements != null) {
for (StackTraceElement element : stackTraceElements) {
if (element.isNativeMethod()) {
continue;
} else if (element.getClassName().equals(Thread.class.getName())) {
continue;
} else if (element.getClassName().equals(LogUtil.class.getName())) {
continue;
}
return "[ Thread:" + Thread.currentThread().getName() + ", at " + element.getClassName() + "." + element.getMethodName()
+ "(" + element.getFileName() + ":" + element.getLineNumber() + ")" + " ]";
}

}
return null;
}

/**
* 各级别日志具体实现方法
*/
public static void v(String msg) {
if (isDebuggable())
Log.v(TAG, msg + "-----" + getDetail());
}
public static void d(String msg) {
if (isDebuggable())
Log.d(TAG, msg + "-----" + getDetail());
}
public static void i(String msg) {
if (isDebuggable())
Log.i(TAG, msg + "-----" + getDetail());
}
public static void w(String msg) {
if (isDebuggable())
Log.w(TAG, msg + "-----" + getDetail());
}
public static void e(String msg) {
if (isDebuggable())
Log.e(TAG, msg + "-----" + getDetail());
}
public static void wtf(String msg) {
if (isDebuggable())
Log.wtf(TAG, msg + "-----" + getDetail());
}
}

单位转换工具

这里单指手机分辨率对应的各种分辨率之间的转换,安卓由于平台的开放性,设备型号百花齐放,所以屏幕适配简直让人抓狂,写代码处理动画或者定位时当然就不能通过简单的数字来写,那样在不同的尺寸中,得到的效果不同,用户体验差。所以下面的这几个方法还是很常用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class DensityUtil {

/**
* 根据手机的分辨率从 dp 的单位 转成为 px(像素)
*/
public static int dip2px(Context context, float dpValue) {
final float scale = getScale(context);
return (int) (dpValue * scale + 0.5f);
}

/**
* 根据手机的分辨率从 px(像素) 的单位 转成为 dp
*/
public static int px2dip(Context context, float pxValue) {
final float scale = getScale(context);
return (int) (pxValue / scale + 0.5f);
}

public static int px2sp(Context context, float pxValue) {
final float fontScale = getScale(context);
return (int) (pxValue / fontScale + 0.5f);
}

public static int sp2px(Context context, float spValue) {
final float fontScale = getScale(context);
return (int) (spValue * fontScale + 0.5f);
}

private static float getScale(Context context) {
final float fontScale = context.getResources().getDisplayMetrics().scaledDensity;
return findScale(fontScale);
}
private static float findScale(float scale){
if(scale<=1){
scale=1;
}else if(scale<=1.5){
scale=1.5f;
}else if(scale<=2){
scale=2f;
}else if(scale<=3){
scale=3f;
}
return scale;
}
}

其他

还有一些例如弹窗工具、Json解析、倒计时工具(短信验证码用)等,就不一一贴了。这个项目放在了我github上,可以随便看看。

正式敲代码

准备工作做好,就是正常的流程,拿到美工提供的切图和尺寸,写layout、drawable,不要忘了测试过度绘制,这个流程还是很快的,接着就是给各页面建立逻辑关系、接后台接口等。这里不可能贴整个代码,就提一些觉得和原来工作中不一样的地方吧。

Gradle中引入的依赖

我看了很多sdk的support包,他们选择都是v4,然而我一直想不通为什么,直到现在我也没明白。我个人认为是当初最早大家都用v4,一直没有更新换代,所以我选择了v7,目前没感觉会有什么问题。另外,其他的功能,我感觉应该尽量都选择源生开发,就像之前提过,尽量减少给接入方处理的麻烦,才能提高用户体验,毕竟sdk的用户不仅是玩家,也是开发者。

登录/注册页面的view切换

  • 页面多,activity又要尽量的少:玩过手游都知道,一般会有用户登录、短信登录、用户注册、短信注册,页面较多,各页面间切换也要考虑完善。为了方便接入,在一个activity中以view栈的方式处理。可在baseActivity中加入以下代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    protected Stack<View> mViewStack = new Stack<>();

    /**
    * 使用压栈方式存放各view,便于流程处理
    */
    protected void pushView2Stack(View view) {
    if (mViewStack.size() > 0) {
    View peekView = mViewStack.peek();
    peekView.clearFocus();
    }
    mViewStack.push(view);
    setContentView(view);
    view.requestFocus();
    }

    /**
    * 弹栈方式获取下层view,处理返回按钮流程
    */
    protected void popViewFromStack() {
    if (mViewStack.size() > 1) {
    View popView = mViewStack.pop();
    popView.clearFocus();
    View view = mViewStack.peek();
    setContentView(view);
    view.requestFocus();
    } else {
    finish();
    }
    }

    /**
    * 判断此时view栈是否处于栈顶
    */
    protected boolean isPeek() {
    return mViewStack.size() <= 1;
    }
  • 透明度处理:v4包下,一般是将activity直接当做dialog,继承其style,并去掉标题栏。由于选择了v7包,处理稍有不同。这里另外要注意的是,每个需要接入方注册的activity要设置相同的theme才能使screenOrientation属性中的behind生效。

    1
    2
    3
    4
    5
    6
    <!-- 继承AppCompat才能适配v7下的透明,半透明一定要设置第二项 -->
    <style name="YXJTransparent" parent="@style/Theme.AppCompat.Light.NoActionBar">
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:windowIsTranslucent">true</item>
    <item name="android:windowAnimationStyle">@style/Animation.AppCompat.Dialog</item>
    </style>
  • 重写返回按钮逻辑:结合base类中view栈相关方法,页面内按钮间切换压栈,返回键弹栈。取到栈顶时finish,表示取消登录。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * 根据栈中情况处理页面逻辑
    */
    @Override
    public void onBackPressed() {
    if (isPeek()) {
    popViewFromStack();
    Toast.makeText(this, "取消登录", Toast.LENGTH_SHORT).show();
    //todo 登录失败回调
    } else {
    popViewFromStack();
    }
    }

悬浮窗

  • 单例模式:悬浮窗应该只存在一个,双重判空上锁加volatile修饰创建实例。这样会造成传入context的要声明为static,黄色警告线提示可能内存泄漏,没有找到很好的解决办法,后来想清楚,觉得不需要担心,因为会在内部流程中详细的说明它的产生和销毁过程,完全能够避免,所以这个问题再可控范围内。

  • 悬浮窗适配类型以及绕过权限验证:原来可以通过LayoutParams中的type属性,把悬浮窗设置为dialog相类似的类型,从而绕过系统验证,然而android8.0以后已经封死了这个,所以现在网上绕过权限的方法,都不可行,只要你是悬浮窗,在高版本的系统中就一定需要申请权限。

    思考之后,有了以下解决办法,那就是游戏中的浮标,其实只应该存在于游戏中,在退出或者返回桌面时,并不需要也显示在桌面中,所以,它可以不被定义为悬浮窗,又由于手游一般就是一个activity承载整个游戏,我们只要让其传入该活动而不是context,就能够直接在其内部添加一个view,就仿佛看起来是悬浮窗,实际只是植入一个可拖动的按钮。关键代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //初始化代码
    public void init(Activity activity) {
    //传入游戏所在activity,而不能只是context
    mActivity = activity;
    //获取屏幕尺寸
    DisplayMetrics metrics = new DisplayMetrics();
    activity.getWindowManager().getDefaultDisplay().getMetrics(metrics);
    WIDTH = metrics.widthPixels;
    HEIGHT = metrics.heightPixels;
    //创建悬浮球布局
    mFrameLayout = new FrameLayout(activity);
    mParams = new FrameLayout.LayoutParams(DensityUtil.dip2px(activity, 50), DensityUtil.dip2px(activity, 50));
    //设置初始属性
    mFrameLayout.setBackgroundResource(MResource.getIdByName(activity, "drawable", "logo"));
    mParams.gravity = Gravity.START | Gravity.TOP;
    //此为关键点:get到游戏所在活动的内容窗口,将浮标view添加进去
    activity.getWindow().addContentView(mFrameLayout, mParams);
    ...
    }
  • 事件处理:由于浮标包含拖动、停靠、点击等各种行为,所以直接让onTouchListener返回true在其中通过事件处理点击,拦截掉onClickListener,用户体验会提高。否则的话,单独在click中处理会导致拖拽后也会判定为点击,弹出菜单,而大多数时候用户只是想把浮标拖到其他位置而已。
  • 悬停:如果最后浮标停留在屏幕中间,应该能够停靠于边缘并在一段时间后隐藏。这里我用了位移动画并在动画结束时处理浮标属性,因为位移动画只是变化表象,点击事件还停留在原来位置。为什么不用属性动画,你试一下就知道了,属性动画也只是改变了它的translation,而我们需要真正的改变坐标,也可能是我处理方式不对,会出一些怪问题,没有解决。

游戏退出

根据很多大厂sdk的处理来看,最好把游戏的退出也交给sdk处理,提供接口给游戏方,便于更好的回收资源,减少崩溃情况的发生。

小结

感觉事实上,sdk开发还是比app要简单一些,只是很多习惯上的细节需要注意。在这个过程中,对事件分发有了更好的理解,相信也可以逐渐的写自定义view了。积累了一些经验,继续努力进阶,加油,我相信安卓还有下一个十年。

Powered by Hexo

Copyright © 2018 - 2022 Yshen's Blog All Rights Reserved.

UV : | PV :

Fork me on GitHub