安卓软件开发工具包快速入门

云眼About 12 min

安卓软件开发工具包快速入门

欢迎阅读 云眼灰度发布 的 Android SDK 快速入门指南。

按照本指南中的步骤可以完成创建灰度发布标帜、推出标帜,以及运行 A/B 测试等操作。

第一部分:创建和配置灰度发布标帜

1. 创建“全栈灰度发布”项目

需要一个 云眼帐户才能完成本指南的操作。如果还没有帐户,可以在云眼平台(https://app.eyeofcloud.com/register)注册一个免费帐户。如果已有帐户,请打开或创建一个新的灰度发布项目。open in new window

对于新注册的帐号,在欢迎页面里点击“创建灰度发布项目”。

对于已经建立项目的帐号,点击切换项目->新建项目->创建灰度发布项目,即可创建新项目。

create_project_created.gif

2. 在项目中创建buy标帜

灰度发布项目创建完毕后,转到主页面菜单栏上“灰度发布”页面,点击创建标帜,将新标帜命名为“buy”。

3. 在buy标帜中添加变量

buy标帜建立后,点击此标帜,在标帜设置-缺省变量处依次添加变量,本案例为了简化操作和节省时间,变量名称和缺省值都采用云眼平台提供的默认值。

4. 在事件管理中增加事件buy

转到菜单栏上“事件管理”页面,点击新建事件按钮。将事件标识命名为buy,点击创建事件,创建完成。

第二部分:编码实现灰度发布和AB测试

1. 安装 云眼灰度发布 Android SDK。

在项目的build.gradle中,将MavenCentral添加到存储库。

build.gradle

repositories {
     mavenCentral()
     google()
     jcenter()
 }

在模块的build.gradle中,添加云眼 Android SDK依赖项,并通过单击立即同步来同步gradle。

build.gradle

dependencies {
    implementation "com.eyeofcloud.ab:android-sdk:3.13.4"
    
    implementation("com.eyeofcloud.ab:core-api:3.1.0"){
        //如果出现重复类加载问题,可以类似如下进行排除
        exclude group: 'javax.annotation'
        exclude module: 'annotations'
    }
    implementation "org.slf4j:slf4j-api:1.7.30"

    implementation "androidx.work:work-runtime:2.7.1"
    implementation "commons-codec:commons-codec:1.15"
}

云眼 Android SDK通过MavenCentralopen in new window分发。完整的源代码可在giteeopen in new window上找到。

2. 调用SDK方法,创建eyeofcloudClient实例

在代码合适的位置(在 MyApplication.java 文件里)创建eyeofcloudClient实例,并把实例放在全局变量中,以便后续使用它。创建eyeofcloudClient实例需要传入sdkKey。本演示程序为了演示方便从页面输入sdkKey,但是实际应用时,需要直接写在代码里。sdkKey可以在云眼平台上拿到。

在代码能够获得用户id和属性信息的位置,通过调用eyeofcloudClient实例的方法createUserContext,得到user对象,user对象非常重要,它包含AB测试需要的2个主要方法decide和trackEvent。调用createUserContext需要传入两个参数:userId 和 attributes。 可以用设备加随机数作为userId,只要能够唯一识别用户即可。attributes是key、value对组成的数组,一般基于业务系统中的用户属性信息来构建。 user对象产生后,可以把它作为属性变量添加到app对象上,以便在其他地方可以使用。

MyApplication.java

package com.eyeofcloud.demo.flag;

import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;

import com.eyeofcloud.ab.EyeofcloudUserContext;
import com.eyeofcloud.ab.android.sdk.EyeofcloudClient;
import com.eyeofcloud.ab.android.sdk.EyeofcloudManager;

import org.json.JSONObject;

import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class MyApplication extends Application {
    public static MyApplication app;

    final public static String DEFUAULT_SDK_KEY = "1000122_2e2d7fbdea1afc51";
    private EyeofcloudManager eyeofcloudManager;
    private EyeofcloudUserContext user;
    private String sdkKey;
    private Map<String, Object> attributes;
    final public static String USER_ID = "userId";
    final public static String SDK_KEY = "sdkKey";
    final public static String ATTRIBUTES = "attributes";

    public static MyApplication getInstance() {
        return app;
    }

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

    public void initEyeofcloud() {
        //List<EyeofcloudDecideOption> defaultDecideOptions = Arrays.asList(EyeofcloudDecideOption.DISABLE_DECISION_EVENT);

        EyeofcloudManager eyeofcloudManager = EyeofcloudManager.builder()
                .withEventDispatchInterval(1L, TimeUnit.SECONDS)
                .withDatafileDownloadInterval(15, TimeUnit.MINUTES)
                .withSDKKey(app.getSdkKey())
                //.withDefaultDecideOptions(defaultDecideOptions)
                .build(app.getApplicationContext());
        //eyeofcloudManager.initialize(app.getApplicationContext(), R.raw.datafile, eyeofcloudClient -> {
        // On AVD, it's reported that setRequiredNetworkType(NetworkType.CONNECTED) not work, if so we can use below code
        // which can skip calling of setRequiredNetworkType(NetworkType.CONNECTED). On physical mobile, below code is not required and should be commented out.
        // WorkerScheduler.requestOnlyWhenConnected = false;
        EyeofcloudClient eyeofcloudClient = eyeofcloudManager.initialize(app.getApplicationContext(), R.raw.datafile); //, eyeofcloudClient -> {
        if (eyeofcloudClient.isValid()) {
            // createUserContext
            String userId = app.getAnonUserId();
            Map<String, Object> attributes = app.getAttributes();
            user = eyeofcloudClient.createUserContext(userId, attributes);
            // attributes can be set in this way too
            user.setAttribute("Country", "中国");

            user.trackEvent("buy");
        }

    }

    public EyeofcloudUserContext getUser() {
        return user;
    }

    public String getSdkKey() {
        if (sdkKey == null || sdkKey.isEmpty()) {
            SharedPreferences sharedPreferences = getApplicationContext().getSharedPreferences(SDK_KEY, Context.MODE_PRIVATE);
            sdkKey = sharedPreferences.getString(SDK_KEY, null);
        }
        return sdkKey;
    }

    public void setSdkKey(String sdkKey) {
        this.sdkKey = sdkKey;
        if (sdkKey != null) {
            SharedPreferences sharedPreferences = getApplicationContext().getSharedPreferences(SDK_KEY, Context.MODE_PRIVATE);
            sharedPreferences.edit().putString(SDK_KEY, sdkKey).apply();
        }
    }

    public Map<String, Object> getAttributes() {
        if (attributes == null || attributes.isEmpty()) {
            attributes = new HashMap<>();
            SharedPreferences sharedPreferences = getApplicationContext().getSharedPreferences(ATTRIBUTES, Context.MODE_PRIVATE);
            try {
                JSONObject attributesJson = new JSONObject(sharedPreferences.getString(ATTRIBUTES, null));
                Iterator<String> keysItr = attributesJson.keys();
                while (keysItr.hasNext()) {
                    String key = keysItr.next();
                    attributes.put(key, attributesJson.get(key));
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return attributes;
    }

    public void setAttributes(Map<String, Object> attributes) {
        this.attributes = attributes;
        JSONObject jsonAttributes = new JSONObject(attributes);
        SharedPreferences sharedPreferences = getApplicationContext().getSharedPreferences(ATTRIBUTES, Context.MODE_PRIVATE);
        sharedPreferences.edit().putString(ATTRIBUTES, jsonAttributes.toString()).apply();
    }

    public String getAnonUserId() {
        // this is a convenience method that creates and persists an anonymous user id,
        // which we need to pass into the activate and track calls
        SharedPreferences sharedPreferences = getApplicationContext().getSharedPreferences(USER_ID, Context.MODE_PRIVATE);
        String id = sharedPreferences.getString(USER_ID, null);
        if (id == null) {
            id = Build.MODEL + new Date().getTime();
            // comment this out to get a brand new user id every time this function is called.
            // useful for incrementing results page count for QA purposes
            sharedPreferences.edit().putString(USER_ID, id).apply();
        }
        return id;
    }

    public static String getConnectivityStatusString(Context context) {
        String status = null;
        ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
        NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
        if (activeNetwork != null) {
            if (activeNetwork.getType() == ConnectivityManager.TYPE_WIFI) {
                status = "Wifi enabled";
                return status;
            } else if (activeNetwork.getType() == ConnectivityManager.TYPE_MOBILE) {
                status = "Mobile data enabled";
                return status;
            }
        } else {
            status = "No internet is available";
            return status;
        }
        return status;
    }
}

3. 调用decide(),获取并使用变量。

在buy页面加载时,调用user的一个重要方法:decide,获得当前用户在灰度标帜buy的抽签分桶结果decision对象,decision里包含抽中的各个变量的值。我们可以用这些变量值控制页面显示,业务逻辑,AI参数,推荐算法等。

BuyViewModel.java

package com.eyeofcloud.demo.flag.ui.buy;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;

import com.eyeofcloud.demo.flag.MyApplication;
import com.eyeofcloud.demo.flag.R;
import com.eyeofcloud.demo.flag.databinding.FragmentBuyBinding;

public class BuyFragment extends Fragment {

    private FragmentBuyBinding binding;

    public View onCreateView(@NonNull LayoutInflater inflater,
                             ViewGroup container, Bundle savedInstanceState) {

        MyApplication app = (MyApplication) getActivity().getApplication();

        BuyViewModel buyViewModel =
                new ViewModelProvider(this).get(BuyViewModel.class);

        binding = FragmentBuyBinding.inflate(inflater, container, false);
        View root = binding.getRoot();

        final TextView buttonTextView = binding.button;
        buyViewModel.getStringVariable().observe(getViewLifecycleOwner(), buttonTextView::setText);

        final TextView ruleKeyTextView = binding.ruleKey;
        buyViewModel.getRuleKey().observe(getViewLifecycleOwner(), ruleKeyTextView::setText);

        final TextView variationKeyTextView = binding.variationKey;
        buyViewModel.getVariationKey().observe(getViewLifecycleOwner(), variationKeyTextView::setText);

        final TextView stringTextView = binding.stringValue;
        buyViewModel.getStringVariable().observe(getViewLifecycleOwner(), stringTextView::setText);

        Button button = binding.button;
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // trackEvent
                app.getUser().trackEvent("buy");
            }
        });

        if (app.getSdkKey() != null && !app.getSdkKey().isEmpty()) {
            buyViewModel.init(app);
        } else {
            AlertDialog.Builder builder = new AlertDialog.Builder(getContext());

            String connection = MyApplication.getConnectivityStatusString(getContext());
            // Set the message show for the Alert time
            builder.setMessage("Connection status: " + connection +",SDK Key尚未设置,是否使用缺省SDK Key:" + MyApplication.DEFUAULT_SDK_KEY + "?");
            // Set Alert Title
            builder.setTitle("注意!");
            // Set Cancelable false for when the user clicks on the outside the Dialog Box then it will remain show
            builder.setCancelable(false);
            // Set the positive button with yes name Lambda OnClickListener method is use of DialogInterface interface.
            builder.setPositiveButton("是,用缺省", (dialog, which) -> {
                // When the user click yes button then app will close
                //finish();
                app.setSdkKey(MyApplication.DEFUAULT_SDK_KEY);
                buyViewModel.init(app);
                NavController navController = Navigation.findNavController(getActivity(), R.id.nav_host_fragment_activity_main);
                navController.navigate(R.id.navigation_buy);
            });
            // Set the Negative button with No name Lambda OnClickListener method is use of DialogInterface interface.
            builder.setNegativeButton("否,去设置", (dialog, which) -> {
                // If user click no then dialog box is canceled.
                dialog.cancel();
                NavController navController = Navigation.findNavController(getActivity(), R.id.nav_host_fragment_activity_main);
                navController.navigate(R.id.navigation_config);
            });
            // Create the Alert dialog
            AlertDialog alertDialog = builder.create();
            // Show the Alert Dialog box
            alertDialog.show();
        }
        return root;
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        binding = null;
    }

}

4. 调用trackEvent方法,将事件发出

BuyFragment.java

package com.eyeofcloud.demo.flag.ui.buy;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;

import com.eyeofcloud.demo.flag.MyApplication;
import com.eyeofcloud.demo.flag.R;
import com.eyeofcloud.demo.flag.databinding.FragmentBuyBinding;

public class BuyFragment extends Fragment {

    private FragmentBuyBinding binding;

    public View onCreateView(@NonNull LayoutInflater inflater,
                             ViewGroup container, Bundle savedInstanceState) {

        MyApplication app = (MyApplication) getActivity().getApplication();

        BuyViewModel buyViewModel =
                new ViewModelProvider(this).get(BuyViewModel.class);

        binding = FragmentBuyBinding.inflate(inflater, container, false);
        View root = binding.getRoot();

        final TextView buttonTextView = binding.button;
        buyViewModel.getStringVariable().observe(getViewLifecycleOwner(), buttonTextView::setText);

        final TextView ruleKeyTextView = binding.ruleKey;
        buyViewModel.getRuleKey().observe(getViewLifecycleOwner(), ruleKeyTextView::setText);

        final TextView variationKeyTextView = binding.variationKey;
        buyViewModel.getVariationKey().observe(getViewLifecycleOwner(), variationKeyTextView::setText);

        final TextView stringTextView = binding.stringValue;
        buyViewModel.getStringVariable().observe(getViewLifecycleOwner(), stringTextView::setText);

        Button button = binding.button;
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // trackEvent
                app.getUser().trackEvent("buy");
            }
        });

        if (app.getSdkKey() != null && !app.getSdkKey().isEmpty()) {
            buyViewModel.init(app);
        } else {
            AlertDialog.Builder builder = new AlertDialog.Builder(getContext());

            String connection = MyApplication.getConnectivityStatusString(getContext());
            // Set the message show for the Alert time
            builder.setMessage("Connection status: " + connection +",SDK Key尚未设置,是否使用缺省SDK Key:" + MyApplication.DEFUAULT_SDK_KEY + "?");
            // Set Alert Title
            builder.setTitle("注意!");
            // Set Cancelable false for when the user clicks on the outside the Dialog Box then it will remain show
            builder.setCancelable(false);
            // Set the positive button with yes name Lambda OnClickListener method is use of DialogInterface interface.
            builder.setPositiveButton("是,用缺省", (dialog, which) -> {
                // When the user click yes button then app will close
                //finish();
                app.setSdkKey(MyApplication.DEFUAULT_SDK_KEY);
                buyViewModel.init(app);
                NavController navController = Navigation.findNavController(getActivity(), R.id.nav_host_fragment_activity_main);
                navController.navigate(R.id.navigation_buy);
            });
            // Set the Negative button with No name Lambda OnClickListener method is use of DialogInterface interface.
            builder.setNegativeButton("否,去设置", (dialog, which) -> {
                // If user click no then dialog box is canceled.
                dialog.cancel();
                NavController navController = Navigation.findNavController(getActivity(), R.id.nav_host_fragment_activity_main);
                navController.navigate(R.id.navigation_config);
            });
            // Create the Alert dialog
            AlertDialog alertDialog = builder.create();
            // Show the Alert Dialog box
            alertDialog.show();
        }
        return root;
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        binding = null;
    }

}

5. 运行一下小程序

至此,编码工作完成,但是灰度发布标志还没有启动。我们先看一下在不启动灰度标帜,用缺省值来运行小程序时界面的样子。此时,按钮显示的是缺省的“购买”两个字。

第三部分:运行灰度发布实验

小程序产品或运营人员,在云眼灰度发布平台上操作来改变按钮的文字,并跟踪和评估各个变体的转化效果。

1. 修改buy标帜变量形成不同变体

点击buy标帜,将缺省变量中string variable的默认值设置为“购买”。添加两个变体:变体1和变体2,变体1中设置string variable值为“立即购买”,变体2中设置string variable值为“我想要”。

创建变体
create_variation.gif

2. 为灰度标帜buy配置AB测试规则

配置完buy标帜的变量和变体后,开始配置buy标帜的AB测试规则,在development环境下点击“增加规则”->“AB测试”, 实验受众选择缺省的所有访客,百分比为100%,指标设置为事件buy的唯一转化,变体1和变体2的流量权重设置为50%对50% 点击保存。

创建变体
config_ab_experiment.gif

3. 启动并运行灰度发布。

buy标帜规则设置完成后,我们打开buy标帜。

enable_flag.png

然后模拟用户访问buy页面。可以看到,此次访问结果为变体1,购买按钮的文字信息为“立即购买”,说明将用户分桶到了变体1。我们可以卸载并重新访问小程序,来模拟另一个新用户,这次购买按钮的文字信息是“我想要”,说明用户分桶到了变体2。实际运营时,可以做很多种实验,比如定向到不同受众,配置各种类型的指标,以及人工智能流量调整等,我们会有专门视频来讲解。

|||

第四部分、分析结果,推出获胜变体

分析结果,推出获胜变体。小程序灰度发布标帜推出一段时间后,我们分析实验结果,根据实验结果推出获胜变体。

1. 查看实验报告,分析结果。

点击buy标帜上AB测试中的“查看结果”按钮。

根据变体1和变体2主指标数值和统计显著性来判定获胜者,统计显著性一般要大于95%。本案例中可以看出变体1胜出,指标提升55.69%,统计显著性达到98%。

2. 根据实验结果推出获胜变体。

确定胜出变体后,我们可以将将少部分流量(比如5%)继续用于AB测试,其余流量(95%)推出胜出的变体1。

本教程只是指导您完成最简单的灰度发布,在灰度发布之前进行 A/B 测试。

下表显示灰度发布和 A/B 测试之间的差异:

灰度发布A/B 测试规则
可以将标帜推广到一定比例的一般用户群(或特定受众),或者在遇到错误时回滚。在投资交付之前,通过 A/B 测试标帜进行实验,这样您就知道要构建什么。跟踪用户在标帜变体中的行为,然后使用 云眼平台 统计引擎解释实验结果。

回顾和结论

为了实施本案例的AB测试,我们总共做了4部分的工作,可以简单概括为:实验的设计、实现、实施和行动,与PDCA过程类似。

每部分工作都需要处理AB测试相关的信息,并由相关人员来负责完成,我们把参与AB测试的人员分为业务人员和技术人员两大类。

部分工作内容概括处理信息参与者
创建灰度发布标帜设计变量、事件业务人员:产品、运营、数据; 技术人员:开发、QA
灰度发布编码实现实现变量、事件技术人员:开发、QA
配置并运行灰度发布实施实验、变体、指标、流量业务人员:产品、运营、数据
分析结果,推出获胜变体行动实验、变体、指标、流量、结果、推出业务人员:产品、运营、数据

从表格中,我们可以看到,只有第一部分工作是由业务人员和技术人员共同完成的,而他们共同处理的信息只有变量和事件,在后面的第三和第四部分中更多的信息,技术人员不需要知道。这样的好处是,可以尽最大可能减少业务人员和技术人员之间的相互依赖。业务人员在实施AB测试过程中,不需要依赖技术人员;技术人员在实现AB测试时也不依赖业务人员。大家可以相对独立、高效的进行工作。这是云眼灰度发布/AB测试方案的一大优势。 另外,云眼AB测试结束后可以无缝的实现对特定人群的定向发布,做到AB测试和灰度发布的有机结合,这是云眼产品的另一大特点。此外,云眼产品在AB测试/灰度发布各个环节里还有很多独特的功能,我们将在后续的视频中详细介绍。

祝贺!您已成功设置并启动了第一个灰度发布实验。虽然此示例侧重于优化销售过程,但 云眼 实验平台可以支持任何场景的实验用例。

培训视频可以从这里观看open in new window

本案例的 Android App demo代码可以从这里获取open in new window

Last update:
Contributors: “zhangweixue”,zhangweixue