带你读《Android全埋点解决方案》之三:$AppStart、$AppEnd全埋点方案

简介: 本书系统讲解了Android全埋点的解决方案,特别是控件点击事件的全埋点采集,总结并归纳了如下8种解决方案,并且都提供了完整的项目源码,适合初级、中级、高级水平的Android开发工程师、技术经理、技术总监等阅读。

点击查看第一章
点击查看第二章

第3章

$AppStart、$AppEnd全埋点方案
对于$AppStart 和$AppEnd 事件而言,归根结底就是判断当前应用程序是处于前台还是处于后台。而 Android 系统本身并没有给应用程序提供相关的接口来判断这些状态,所以我们只能借助其他方式来间接判断。
目前,业界也有很多种方案用来判断一个应用程序是处于前台还是后台,以 Github 上的一个开源项目为例:https://github.com/wenmingvs/AndroidProcess
这个开源项目提供了 6 种方案。这 6 种方案的综合对比可以参考表3-1。

image.png

以上6种方案,各有优缺点,但都无法解决我们最关心的几个问题:
□应用程序如果有多个进程该如何判断?
□应用程序如果发生崩溃了该如何判断?
□应用程序如果被强杀了又该如何判断?

3.1 原理概述

针对上面列出的3个问题,我们下面将一一进行分析并解决。
(1)应用程序如果有多个进程该如何判断是处于前台还是处于后台?
众所周知,一个 Android 应用程序是可以有多个进程同时存在的,所以这就加大了我们判断一个应用程序是处于前台还是处于后台的难度,继而导致很多常见的判断方案也都会失效。
其实,对于这个问题,可以归于应用程序多进程间的数据共享问题。
Android系统中支持多进程通信方式主要有以下几种,它们各有优缺点。
□AIDL
AIDL 的功能相对来说比较强大,支持进程间一对多的实时并发通信,并且可以实现 RPC(远程过程调用)。
□Messenger
Messenger支持一对多的串行实时通信,它相当于是 AIDL的简化版本。
□Bundle
Bundle 是Android 系统中四大组件的进程间通信方式,目前只能传输 Bundle 支持的数据类型,比如 String、int 等。
□ContentProvider
ContentProvider是一个非常强大的数据源访问组件,主要支持 CRUD操作和一对多的进程间数据共享,例如我们的应用访问系统的图库数据。
□BroadcastReceiver
BroadcastReceiver即广播,目前只能支持单向通信,接收者只能被动地接收消息。
□文件共享
文件共享主要适用于在非高并发情况下共享一些比较简单的数据。
□Socket
Socket 主要通过网络传输数据。
我们目前的方案主要是采用ContentProvider 机制来解决进程间的数据共享问题。ContentProvider 是基于 Binder 机制封装的系统组件,天生就是用来解决跨进程间的数据共享问题的。另一方面,Android 系统也提供了针对ContentProvider 的数据回调监听机制—即 ContentObserver,这样就更加方便我们来处理跨进程间的数据通信方面的问题。
一般情况下,解决跨进程数据共享的问题,普遍采用的是 ContentProvider + SQLite3方案,但是鉴于目前我们面临的实际情况,使用 SQLite3数据库来存储一些简单的数据和标记位,明显太过重量级了。通常在 Android 系统以及应用程序开发中,针对一些比较简单的数据的存储,一般采用SharedPreferences,从而可以做到快速读写。所以我们目前采用“ContentProvider + SharedPreferences”的方案来解决跨进程数据共享的问题。
(2)应用程序如果发生崩溃或者被强杀了该如何判断该应用程序是处于前台还是处于后台?
对于应用程序发生崩溃或者应用进程被强杀的场景,我们引入了 Session 的概念。简单理解就是:对于一个应用程序,当它的一个页面退出了,如果在 30s 之内没有新的页面打开,我们就认为这个应用程序处于后台了(触发$AppEnd事件);当它的一个页面显示出来了,如果与上一个页面的退出时间的间隔超过了 30s,我们就认为这个应用程序重新处于前台了(触发$AppStart事件)。此时,Session 的间隔我们是以 30s 为例。
总体来说,我们首先注册一个Application.ActivityLifecycleCallbacks 回调,用来监听应用程序内所有 Activity 的生命周期。然后我们再分两种情况分别进行处理。
在页面退出的时候(即onPause生命周期函数),我们会启动一个 30s 的倒计时,如果 30s 之内没有新的页面进来(或显示),则触发 $AppEnd 事件;如果有新的页面进来(或显示),则存储一个标记位来标记已有新的页面进来。这里需要注意的是,由于 Activity 之间可能是跨进程的(即给 Activity 设置了 android:process 属性),所以标记位需要实现进程间的共享,即通过 ContentProvider + SharedPreferences 来进行存储。然后通过 ContentObserver 监听到新页面进来的标记位改变,从而可以取消上个页面退出时启动的倒计时。如果 30s 之内没有新的页面进来(比如用户按 Home 键/返回键退出应用程序、应用程序发生崩溃、应用程序被强杀),则会触发 $AppEnd 事件,或者在下次启动的时候补发一个 $AppEnd 事件。之所以要补发 $AppEnd 事件,是因为对于一些特殊的情况(应用程序发生崩溃、应用程序被强杀),应用程序可能停止运行了,导致我们无法及时触发 $AppEnd 事件,只能在用户下次启动应用程序的时候进行补发。当然,如果用户再也不去启动应用程序或者将应用程序卸载,就会导致“丢失” $AppEnd 事件。
在页面启动的时候(即onStart生命周期函数),我们需要判断一下与上个页面的退出时间间隔是否超过了 30s,如果没有超过 30s,则直接触发 $AppViewScreen 事件。如果已超过了 30s,我们则需要判断之前是否已经触发了 $AppEnd 事件(因为如果 App 崩溃了或者被强杀了,可能没有触发 $AppEnd 事件),如果没有,则先触发 $AppEnd 事件,然后再触发 $AppStart 和 $AppViewScreen事件。

3.2 案例

针对上面介绍的原理,接下来我们将详细介绍如何实现$AppStart和$AppEnd 事件的全埋点方案。
完整的项目源码可以参考:https://github.com/wangzhzh/AutoTrackAppStartAppEnd
第1步:新建一个项目(Project)
在新建的项目中,会自动包含一个主 module,即:app。
第2步:创建 sdk module
新建一个 Android Library module,名称叫 sdk,这个模块就是我们的埋点 SDK模块。
第3步:添加依赖关系
app module需要依赖sdk module。可以通过修改app/build.gradle文件,在其dependencies 节点中添加依赖关系:
apply plugin: 'com.android.application'

android {

compileSdkVersion 28
defaultConfig {
    applicationId "com.sensorsdata.analytics.android.app.startend"
    minSdkVersion 15
    targetSdkVersion 28
    versionCode 1
    versionName "1.0"
}
buildTypes {
    release {
        minifyEnabled false
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

}

dependencies {

implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0-rc02'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'

implementation project(':sdk')

}
第4步:编写埋点 SDK
在sdk module 中我们新建一个埋点 SDK 的主类,即SensorsDataAPI.java,完整的源码可以参考如下:
package com.sensorsdata.analytics.android.sdk;

import android.app.Application;
import android.support.annotation.Keep;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;

import org.json.JSONObject;
import java.util.Map;

image.png

@Keep
public class SensorsDataAPI {

private final String TAG = this.getClass().getSimpleName();
public static final String SDK_VERSION = "1.0.0";
private static SensorsDataAPI INSTANCE;
private static final Object mLock = new Object();
private static Map<String, Object> mDeviceInfo;
private String mDeviceId;

@Keep
@SuppressWarnings("UnusedReturnValue")
public static SensorsDataAPI init(Application application) {
    synchronized (mLock) {
        if (null == INSTANCE) {
            INSTANCE = new SensorsDataAPI(application);
        }
        return INSTANCE;
    }
}

@Keep
public static SensorsDataAPI getInstance() {
    return INSTANCE;
}

private SensorsDataAPI(Application application) {
    mDeviceId = SensorsDataPrivate.getAndroidID(application.getApplicationContext());
    mDeviceInfo = SensorsDataPrivate.getDeviceInfo(application.getApplicationContext());
    SensorsDataPrivate.registerActivityLifecycleCallbacks(application);
    SensorsDataPrivate.registerActivityStateObserver(application);
}

/**
 * track 事件
 *
 * @param eventName  String 事件名称
 * @param properties JSONObject 事件自定义属性
 */
public void track(@NonNull String eventName, @Nullable JSONObject properties) {
    try {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("event", eventName);
        jsonObject.put("device_id", mDeviceId);

        JSONObject sendProperties = new JSONObject(mDeviceInfo);
        if (properties != null) {
            SensorsDataPrivate.mergeJSONObject(properties, sendProperties);
        }

        jsonObject.put("properties", sendProperties);
        jsonObject.put("time", System.currentTimeMillis());

        Log.i(TAG, SensorsDataPrivate.formatJson(jsonObject.toString()));
    } catch (Exception e) {
        e.printStackTrace();
    }
}

}
目前这个主类比较简单,主要包含如下几个方法。
□init(Application application)
这是一个静态方法,是埋点 SDK的初始化函数,它有一个Application类型的参数,内部实现使用到了单例设计模式,然后调用私有构造函数初始化埋点 SDK。app module 就是调用这个方法来初始化我们埋点 SDK 的。
□getInstance()
这也是一个静态方法,通过该方法可以获取埋点 SDK 的实例对象。
□SensorsDataAPI(Application application)
私有的构造函数,也是埋点 SDK 真正的初始化逻辑。在其方法内部通过调用 SDK 的内部私有类SensorsDataPrivate中的方法来注册ActivityLifecycleCallbacks,并给 Content-Provider 注册一个ContentObserver。
□track(@NonNull final String eventName, @Nullable JSONObject properties)
对外公开的 track 事件接口。通过调用该方法可以触发事件,第一个参数 eventName 代表事件的名称,第二个参数properties代表事件的属性。本书为了简化,触发事件仅通过 Log.i 打印了事件的JSON信息。
关于SensorsDataPrivate类中的getAndroidID(Context context)、getDeviceInfo(Context context)、mergeJSONObject(final JSONObject source, JSONObject dest)、formatJson(String jsonStr)等方法实现可以参考工程的源码。
第5步:注册 ActivityLifecycleCallbacks回调
我们是通过调用埋点 SDK 的内部私有类SensorsDataPrivate的registerActivityLifecycleCallbacks(Application application)方法来注册ActivityLifecycleCallbacks的。
image.png

@TargetApi(14)
public static void registerActivityLifecycleCallbacks(Application application) {

mDatabaseHelper = new DatabaseHelper(application.getApplicationContext(), application.getPackageName());
countDownTimer = new CountDownTimer(SESSION_INTERVAL_TIME, 10 * 1000) {
    @Override
    public void onTick(long l) {

    }

    @Override
    public void onFinish() {
        trackAppEnd(mCurrentActivity.get());
    }
};

application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycle-Callbacks() {
    @Override
    public void onActivityCreated(Activity activity, Bundle bundle) {
    }

    @Override
    public void onActivityStarted(Activity activity) {
        mDatabaseHelper.commitAppStart(true);
        double timeDiff = System.currentTimeMillis() - mDatabaseHelper.getAppPausedTime();
        if (timeDiff > 30 * 1000) {
            if (!mDatabaseHelper.getAppEndEventState()) {
                trackAppEnd(activity);
            }
        }

        if (mDatabaseHelper.getAppEndEventState()) {
            mDatabaseHelper.commitAppEndEventState(false);
            trackAppStart(activity);
        }
    }

    @Override
    public void onActivityResumed(Activity activity) {
        trackAppViewScreen(activity);
    }

    @Override
    public void onActivityPaused(Activity activity) {
        mCurrentActivity = new WeakReference<>(activity);
        countDownTimer.start();
        mDatabaseHelper.commitAppPausedTime(System.currentTimeMillis());
    }

    @Override
    public void onActivityStopped(Activity activity) {
    }

    @Override
    public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {

    }

    @Override
    public void onActivityDestroyed(Activity activity) {
    }
});

}
首先初始化一个SensorsDatabaseHelper对象,这个主要是用来操作 ContentProvider 的,然后再初始化一个30s的计时器 CountDownTimer对象,当计时器 finish 的时候,会触发$AppEnd 事件。最后注册Application.ActivityLifecycleCallbacks回调。
在Application.ActivityLifecycleCallbacks 的onActivityStarted(Activity activity)回调方法中,首先修改 AppStart 的标记位,这样之前注册的 ContentObserver 就能收到通知并取消掉 CountDownTimer计时器。然后判断一下当前页面与上个页面退出时间的间隔是否超出了 30s,如果超出了 30s,并且没有触发过$AppEnd 事件(应用程序发生崩溃或者应用程序被强杀等场景),则补发$AppEnd 事件。如果触发了$AppEnd 事件,说明是一个新的 Session 开始了,需要触发$AppStart 事件。
在onActivityResumed(Activity activity)回调方法中,会直接触发$AppViewScreen 页面浏览事件。
在onActivityPaused(Activity activity)回调方法中,启动 CountDownTimer计时器,并且保存当前页面退出时的时间戳。
第6步:定义SensorsDatabaseHelper
package com.sensorsdata.analytics.android.sdk;

import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;

image.png
{

private static final String SensorsDataContentProvider = ".SensorsData-ContentProvider/";
private ContentResolver mContentResolver;
private Uri mAppStart;
private Uri mAppEndState;
private Uri mAppPausedTime;
public static final String APP_STARTED = "$app_started";
public static final String APP_END_STATE = "$app_end_state";
public static final String APP_PAUSED_TIME = "$app_paused_time";
SensorsDatabaseHelper(Context context, String packageName) {
    mContentResolver = context.getContentResolver();
    mAppStart = Uri.parse("content://" + packageName + SensorsDataContentProvider + SensorsDataTable.APP_STARTED.getName());
    mAppEndState = Uri.parse("content://" + packageName + SensorsDataContentProvider + SensorsDataTable.APP_END_STATE.getName());
    mAppPausedTime = Uri.parse("content://" + packageName + SensorsDataContentProvider + SensorsDataTable.APP_PAUSED_TIME.getName());
}
/**
 * Add the AppStart state to the SharedPreferences
 *
 * @param appStart the ActivityState
 */
public void commitAppStart(boolean appStart) {
    ContentValues contentValues = new ContentValues();
    contentValues.put(APP_STARTED, appStart);
    mContentResolver.insert(mAppStart, contentValues);
}

/**
 * Add the Activity paused time to the SharedPreferences
 *
 * @param pausedTime Activity paused time
 */
public void commitAppPausedTime(long pausedTime) {
    ContentValues contentValues = new ContentValues();
    contentValues.put(APP_PAUSED_TIME, pausedTime);
    mContentResolver.insert(mAppPausedTime, contentValues);
}

/**
 * Return the time of Activity paused
 *
 * @return Activity paused time
 */
public long getAppPausedTime() {
    long pausedTime = 0;
    Cursor cursor = mContentResolver.query(mAppPausedTime, new String[]{APP_
PAUSED_TIME}, null, null, null);
    if (cursor != null && cursor.getCount() > 0) {
        while (cursor.moveToNext()) {
            pausedTime = cursor.getLong(0);
        }
    }

    if (cursor != null) {
        cursor.close();
    }
    return pausedTime;
}

/**
 * Add the Activity End to the SharedPreferences
 *
 * @param appEndState the Activity end state
 */
public void commitAppEndEventState(boolean appEndState) {
    ContentValues contentValues = new ContentValues();
    contentValues.put(APP_END_STATE, appEndState);
    mContentResolver.insert(mAppEndState, contentValues);
}

/**
 * Return the state of $AppEnd
 *
 * @return Activity End state
 */
public boolean getAppEndEventState() {
    boolean state = true;
    Cursor cursor = mContentResolver.query(mAppEndState, new String[]{APP_
END_STATE}, null, null, null);
    if (cursor != null && cursor.getCount() > 0) {
        while (cursor.moveToNext()) {
            state = cursor.getInt(0) > 0;
        }
    }

    if (cursor != null) {
        cursor.close();
    }
    return state;
}

public Uri getAppStartUri() {
    return mAppStart;
}

}
这个工具类主要是用来操作 ContentProvider 用来保存相关的数据和标记位。
第7步:定义SensorsDataContentProvider
package com.sensorsdata.analytics.android.sdk;

import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

public class SensorsDataContentProvider extends ContentProvider {

private final static int APP_START = 1;
private final static int APP_END_STATE = 2;
private final static int APP_PAUSED_TIME = 3;

private static SharedPreferences sharedPreferences;
private static SharedPreferences.Editor mEditor;
private static UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private ContentResolver mContentResolver;

@Override
public boolean onCreate() {
    if (getContext() != null) {
        String packName = getContext().getPackageName();
        uriMatcher.addURI(packName + ".SensorsDataContentProvider", Sensors-DataTable.APP_STARTED.getName(), APP_START);
        uriMatcher.addURI(packName + ".SensorsDataContentProvider", Sensors-DataTable.APP_END_STATE.getName(), APP_END_STATE);
        uriMatcher.addURI(packName + ".SensorsDataContentProvider", Sensors-DataTable.APP_PAUSED_TIME.getName(), APP_PAUSED_TIME);
        sharedPreferences = getContext().getSharedPreferences("com.sensorsdata. analytics.android.sdk.SensorsDataAPI", Context.MODE_PRIVATE);
        mEditor = sharedPreferences.edit();
        mEditor.apply();
        mContentResolver = getContext().getContentResolver();
    }
    return false;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
    if (contentValues == null) {
        return uri;
    }
    int code = uriMatcher.match(uri);
    switch (code) {
        case APP_START:
            boolean appStart = contentValues.getAsBoolean(SensorsDatabaseHelper.APP_STARTED);
            mEditor.putBoolean(SensorsDatabaseHelper.APP_STARTED, appStart);
            mContentResolver.notifyChange(uri, null);
            break;
        case APP_END_STATE:
            boolean appEnd = contentValues.getAsBoolean(SensorsDatabaseHelper.APP_END_STATE);
            mEditor.putBoolean(SensorsDatabaseHelper.APP_END_STATE, appEnd);
            break;
        case APP_PAUSED_TIME:
            long pausedTime = contentValues.getAsLong(SensorsDatabaseHelper.APP_PAUSED_TIME);
            mEditor.putLong(SensorsDatabaseHelper.APP_PAUSED_TIME, pausedTime);
            break;
    }
    mEditor.commit();
    return uri;
}

@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] strings, @Nullable String s, @Nullable String[] strings1, @Nullable String s1) {
    int code = uriMatcher.match(uri);
    MatrixCursor matrixCursor = null;
    switch (code) {
        case APP_START:
            int appStart = sharedPreferences.getBoolean(SensorsDatabaseHelper.APP_STARTED, true) ? 1 : 0;
            matrixCursor = new MatrixCursor(new String[]{SensorsDatabase-Helper.APP_STARTED});
            matrixCursor.addRow(new Object[]{appStart});
            break;
        case APP_END_STATE:
            int appEnd = sharedPreferences.getBoolean(SensorsDatabaseHelper.APP_END_STATE, true) ? 1 : 0;
            matrixCursor = new MatrixCursor(new String[]{SensorsDatabase-Helper.APP_END_STATE});
            matrixCursor.addRow(new Object[]{appEnd});
            break;
        case APP_PAUSED_TIME:
            long pausedTime = sharedPreferences.getLong(SensorsDatabase-Helper.APP_PAUSED_TIME, 0);
            matrixCursor = new MatrixCursor(new String[]{SensorsDatabase-Helper.APP_PAUSED_TIME});
            matrixCursor.addRow(new Object[]{pausedTime});
            break;
    }
    return matrixCursor;
}

@Nullable
@Override
public String getType(@NonNull Uri uri) {
    return null;
}

@Override
public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) {
    return 0;
}

@Override
public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) {
    return 0;
}

}
实现了一个ContentProvider,通过操作SharedPreferences来保存数据,可以解决多进程间共享数据的问题,同时也能做到快速读写,提升效率。
SensorsDataTable的定义如下:
package com.sensorsdata.analytics.android.sdk;

image.png

APP_STARTED("app_started"),
APP_PAUSED_TIME("app_paused_time"),
APP_END_STATE("app_end_state");

SensorsDataTable(String name) {
    this.name = name;
}

public String getName() {
    return name;
}

private String name;

}
第8步:初始化埋点 SDK
需要在应用程序自定义的 Application (比如叫 MyApplication)类中初始化 SDK,一般建议在 onCreate() 方法中进行初始化。
package com.sensorsdata.analytics.android.app;
import android.app.Application;

import com.sensorsdata.analytics.android.sdk.SensorsDataAPI;

public class MyApplication extends Application {

@Override
public void onCreate() {
    super.onCreate();
    initSensorsDataAPI(this);
}

/**
 * 初始化埋点 SDK
 *
 * @param application Application
 */
private void initSensorsDataAPI(Application application) {
    SensorsDataAPI.init(application);
}

}
第9步:声明自定义的 Application
以上面定义的 MyApplication 为例,需要在AndroidManifest.xml文件的 application 节点中声明 MyApplication。
<?xml version="1.0" encoding="utf-8"?>
image.png

至此,$AppStart 和$AppEnd 事件的全埋点方案就算完成了。

3.3 缺点

应用程序发生崩溃或者应用程序被强杀等场景,需要下次启动应用程序的时候才能有机会补发 $AppEnd事件。如果用户不再启动应用程序或者将应用程序卸载掉,会导致“丢失” $AppEnd 事件。

相关文章
|
1月前
|
XML Android开发 数据格式
android点击FrameLayout、LinearLayout等父布局没响应的原因以及解决方案
android点击FrameLayout、LinearLayout等父布局没响应的原因以及解决方案
55 2
|
1月前
|
安全 Shell Android开发
Android系统 init.rc sys/class系统节点写不进解决方案和原理分析
Android系统 init.rc sys/class系统节点写不进解决方案和原理分析
61 0
|
1月前
|
Android开发
Android中Glide加载Https图片失败的解决方案
Android中Glide加载Https图片失败的解决方案
29 1
|
1月前
|
安全 编译器 API
Android HAL深入探索(5): 调试HAL报错与解决方案
Android HAL深入探索(5): 调试HAL报错与解决方案
164 1
|
1月前
|
编译器 调度 Android开发
构建高效Android应用:Kotlin协程的优雅解决方案
【4月更文挑战第14天】 在移动开发领域,性能优化和资源管理是提升用户体验的关键因素。随着Kotlin语言在Android平台上的普及,协程作为其核心特性之一,为开发者提供了一种轻量级的并发处理手段。本文将深入探讨Kotlin协程在Android应用中的运用,通过实例分析其如何简化异步任务,提升应用响应性,并保证代码的简洁与可维护性。我们将透过源码剖析、性能对比及最佳实践,揭示协程在现代Android开发中的重要角色。
|
1月前
|
Android开发
Android 新建一个lunch项(全志方案)
Android 新建一个lunch项(全志方案)
30 0
|
1月前
|
存储 应用服务中间件 网络安全
Android 网络链接稳定性测试解决方案
Android 网络链接稳定性测试解决方案
30 0
|
1月前
|
JSON Android开发 数据格式
android 使用GSON 序列化对象出现字段被优化问题解决方案
android 使用GSON 序列化对象出现字段被优化问题解决方案
|
10月前
|
IDE Java 开发工具
Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8的解决方案
Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8的解决方案
|
9月前
|
Android开发
Android Studio 控制台中文乱码,解决方案都在这里了,完美解决
Android Studio 控制台中文乱码,解决方案都在这里了,完美解决