AndroidPdfViewer的灰度发布:使用Feature Flags控制新功能上线

AndroidPdfViewer的灰度发布:使用Feature Flags控制新功能上线

【免费下载链接】AndroidPdfViewer Android view for displaying PDFs rendered with PdfiumAndroid 【免费下载链接】AndroidPdfViewer 项目地址: https://gitcode.com/gh_mirrors/an/AndroidPdfViewer

痛点直击:新功能上线的两难困境

你是否还在为AndroidPdfViewer新功能上线而焦虑?全量发布怕出bug影响所有用户,小范围测试又难以收集足够反馈?本文将带你实现一套基于Feature Flags(功能标志)的灰度发布系统,让你精准控制新功能的发布范围,实现风险可控的平滑迭代。

读完本文你将获得:

  • 一套完整的Feature Flags实现方案
  • 在AndroidPdfViewer中集成灰度发布的具体步骤
  • 动态控制PDF渲染优化功能的上线策略
  • A/B测试框架与用户反馈收集机制

什么是灰度发布与Feature Flags?

灰度发布(Gray Release)是一种增量发布策略,允许新功能先对部分用户开放,逐步扩大范围直至全量发布。这种方式能有效降低发布风险,快速收集真实环境反馈。

Feature Flag(功能标志)是实现灰度发布的核心技术,本质是一种条件判断机制,允许在运行时动态启用或禁用特定功能,而无需修改代码并重新部署应用。

mermaid

AndroidPdfViewer的灰度发布架构设计

基于AndroidPdfViewer的现有架构,我们可以设计一套侵入性低、可扩展性强的灰度发布系统。该系统主要包含四个核心组件:

组件职责技术实现
功能标志管理存储、获取和更新功能标志状态SharedPreferences + 远程配置
标志评估引擎根据规则判断功能是否对用户开放规则匹配算法 + 用户属性
灰度控制API提供简洁的功能开关接口注解 + 建造者模式
反馈收集系统记录功能使用情况和用户行为事件总线 + 本地日志

系统架构图

mermaid

实现步骤1:创建功能标志核心组件

首先,我们需要创建Feature Flag的核心管理类。这些类将负责功能标志的初始化、状态管理和规则评估。

1.1 定义功能标志常量

创建PdfFeatureFlags类,集中管理所有功能标志的键名:

public class PdfFeatureFlags {
    // PDF渲染优化功能标志
    public static final String RENDER_OPTIMIZATION = "pdf_render_optimization_v2";
    
    // 夜间模式增强功能标志
    public static final String NIGHT_MODE_ENHANCEMENT = "pdf_night_mode_enhancement";
    
    // 垂直滑动体验改进
    public static final String VERTICAL_SWIPE_IMPROVEMENT = "pdf_vertical_swipe_improvement";
    
    // 禁止实例化
    private PdfFeatureFlags() {}
}

1.2 实现功能标志管理类

创建FeatureFlagManager类,负责加载、更新和评估功能标志:

public class FeatureFlagManager {
    private static final String PREFS_NAME = "pdf_feature_flags";
    private static final String REMOTE_CONFIG_URL = "https://your-config-server.com/flags";
    
    private static FeatureFlagManager instance;
    private final Context context;
    private final Map<String, Flag> flags = new HashMap<>();
    private final Gson gson = new Gson();
    
    private FeatureFlagManager(Context context) {
        this.context = context.getApplicationContext();
        initDefaultFlags();
        loadLocalFlags();
        fetchRemoteFlags();
    }
    
    public static synchronized void init(Context context) {
        if (instance == null) {
            instance = new FeatureFlagManager(context);
        }
    }
    
    public static FeatureFlagManager getInstance() {
        if (instance == null) {
            throw new IllegalStateException("FeatureFlagManager not initialized. Call init() first.");
        }
        return instance;
    }
    
    private void initDefaultFlags() {
        // 添加默认标志及其规则
        flags.put(PdfFeatureFlags.RENDER_OPTIMIZATION, 
            new Flag(PdfFeatureFlags.RENDER_OPTIMIZATION, false, 
                Collections.singletonList(new Rule("percentage", "lt", "10"))));
        // 添加其他默认标志...
    }
    
    private void loadLocalFlags() {
        SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
        String flagsJson = prefs.getString("flags", null);
        if (flagsJson != null) {
            try {
                Map<String, Flag> savedFlags = gson.fromJson(flagsJson, 
                    new TypeToken<Map<String, Flag>>(){}.getType());
                if (savedFlags != null) {
                    flags.putAll(savedFlags);
                }
            } catch (Exception e) {
                Log.e("FeatureFlagManager", "Failed to parse saved flags", e);
            }
        }
    }
    
    private void fetchRemoteFlags() {
        // 从远程配置服务器获取最新标志状态
        new AsyncTask<Void, Void, String>() {
            @Override
            protected String doInBackground(Void... voids) {
                try {
                    HttpURLConnection connection = (HttpURLConnection) 
                        new URL(REMOTE_CONFIG_URL).openConnection();
                    connection.setRequestMethod("GET");
                    connection.setConnectTimeout(5000);
                    
                    if (connection.getResponseCode() == 200) {
                        BufferedReader reader = new BufferedReader(
                            new InputStreamReader(connection.getInputStream()));
                        StringBuilder response = new StringBuilder();
                        String line;
                        while ((line = reader.readLine()) != null) {
                            response.append(line);
                        }
                        return response.toString();
                    }
                } catch (Exception e) {
                    Log.e("FeatureFlagManager", "Failed to fetch remote flags", e);
                }
                return null;
            }
            
            @Override
            protected void onPostExecute(String response) {
                if (response != null) {
                    updateFlags(response);
                }
            }
        }.execute();
    }
    
    public void updateFlags(String configJson) {
        try {
            Map<String, Flag> remoteFlags = gson.fromJson(configJson,
                new TypeToken<Map<String, Flag>>(){}.getType());
            if (remoteFlags != null) {
                flags.putAll(remoteFlags);
                saveFlags();
                // 发送标志更新事件
                EventBus.getDefault().post(new FlagsUpdatedEvent());
            }
        } catch (Exception e) {
            Log.e("FeatureFlagManager", "Failed to update flags", e);
        }
    }
    
    private void saveFlags() {
        SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
        prefs.edit().putString("flags", gson.toJson(flags)).apply();
    }
    
    public boolean isEnabled(String flagKey) {
        User currentUser = UserManager.getInstance().getCurrentUser();
        return isEnabled(flagKey, currentUser);
    }
    
    public boolean isEnabled(String flagKey, User user) {
        Flag flag = flags.get(flagKey);
        if (flag == null) {
            Log.w("FeatureFlagManager", "Flag not found: " + flagKey);
            return false;
        }
        
        return flag.isEnabled(user);
    }
}

1.3 实现标志与规则评估类

创建Flag类表示单个功能标志:

public class Flag {
    private String key;
    private boolean enabled;
    private List<Rule> rules;
    
    // 默认构造函数,用于JSON反序列化
    public Flag() {}
    
    public Flag(String key, boolean enabled, List<Rule> rules) {
        this.key = key;
        this.enabled = enabled;
        this.rules = rules != null ? rules : Collections.emptyList();
    }
    
    public boolean isEnabled(User user) {
        // 如果标志全局禁用,直接返回false
        if (!enabled) {
            return false;
        }
        
        // 如果没有规则,标志全局启用
        if (rules.isEmpty()) {
            return true;
        }
        
        // 评估所有规则
        for (Rule rule : rules) {
            if (!rule.matches(user)) {
                return false;
            }
        }
        
        return true;
    }
    
    // getter和setter方法
    public String getKey() { return key; }
    public void setKey(String key) { this.key = key; }
    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }
    public List<Rule> getRules() { return rules; }
    public void setRules(List<Rule> rules) { this.rules = rules; }
}

创建Rule类实现规则匹配逻辑:

public class Rule {
    private String type; // "percentage", "user_id", "version", "device"等
    private String operator; // "eq", "ne", "lt", "gt", "contains", "in"等
    private String value;
    
    // 默认构造函数,用于JSON反序列化
    public Rule() {}
    
    public Rule(String type, String operator, String value) {
        this.type = type;
        this.operator = operator;
        this.value = value;
    }
    
    public boolean matches(User user) {
        switch (type) {
            case "percentage":
                return matchesPercentage(user);
            case "user_id":
                return matchesUserId(user);
            case "version":
                return matchesAppVersion(user);
            case "device":
                return matchesDevice(user);
            default:
                Log.w("Rule", "Unknown rule type: " + type);
                return false;
        }
    }
    
    private boolean matchesPercentage(User user) {
        // 基于用户ID的哈希值计算一致的百分比
        int userHash = Math.abs(user.getId().hashCode());
        int userPercentage = userHash % 100;
        int targetPercentage = Integer.parseInt(value);
        
        switch (operator) {
            case "lt":
                return userPercentage < targetPercentage;
            case "le":
                return userPercentage <= targetPercentage;
            case "gt":
                return userPercentage > targetPercentage;
            case "ge":
                return userPercentage >= targetPercentage;
            default:
                return false;
        }
    }
    
    private boolean matchesUserId(User user) {
        Set<String> allowedIds = new HashSet<>(Arrays.asList(value.split(",")));
        return allowedIds.contains(user.getId());
    }
    
    private boolean matchesAppVersion(User user) {
        String appVersion = BuildConfig.VERSION_NAME;
        return compareVersions(appVersion, value, operator);
    }
    
    private boolean matchesDevice(User user) {
        String deviceModel = Build.MODEL;
        switch (operator) {
            case "eq":
                return deviceModel.equals(value);
            case "contains":
                return deviceModel.contains(value);
            default:
                return false;
        }
    }
    
    private boolean compareVersions(String version1, String version2, String operator) {
        String[] parts1 = version1.split("\\.");
        String[] parts2 = version2.split("\\.");
        
        int length = Math.max(parts1.length, parts2.length);
        for (int i = 0; i < length; i++) {
            int v1 = i < parts1.length ? Integer.parseInt(parts1[i]) : 0;
            int v2 = i < parts2.length ? Integer.parseInt(parts2[i]) : 0;
            
            if (v1 != v2) {
                switch (operator) {
                    case "lt": return v1 < v2;
                    case "le": return v1 <= v2;
                    case "gt": return v1 > v2;
                    case "ge": return v1 >= v2;
                    case "eq": return v1 == v2;
                    case "ne": return v1 != v2;
                }
            }
        }
        
        // 版本号相同
        switch (operator) {
            case "lt": return false;
            case "le": return true;
            case "gt": return false;
            case "ge": return true;
            case "eq": return true;
            case "ne": return false;
        }
        
        return false;
    }
    
    // getter和setter方法
    public String getType() { return type; }
    public void setType(String type) { this.type = type; }
    public String getOperator() { return operator; }
    public void setOperator(String operator) { this.operator = operator; }
    public String getValue() { return value; }
    public void setValue(String value) { this.value = value; }
}

实现步骤2:集成Feature Flags到AndroidPdfViewer

现在我们已经有了基础的Feature Flag系统,接下来需要将其集成到AndroidPdfViewer的核心组件中。以PDF渲染优化功能为例,我们将展示如何使用功能标志控制新功能的启用。

2.1 修改PDFView类添加功能开关

PDFView类中添加控制新渲染引擎的方法:

public class PDFView extends RelativeLayout {
    // ... 现有代码 ...
    
    private boolean useNewRenderingEngine = false;
    
    // 添加功能标志检查
    private void initFeatureFlags() {
        // 检查渲染优化功能是否启用
        useNewRenderingEngine = FeatureFlagManager.getInstance()
            .isEnabled(PdfFeatureFlags.RENDER_OPTIMIZATION);
            
        // 注册标志更新事件监听器
        EventBus.getDefault().register(this);
    }
    
    @Subscribe
    public void onFlagsUpdated(FlagsUpdatedEvent event) {
        boolean newRenderingState = FeatureFlagManager.getInstance()
            .isEnabled(PdfFeatureFlags.RENDER_OPTIMIZATION);
            
        if (newRenderingState != useNewRenderingEngine) {
            useNewRenderingEngine = newRenderingState;
            // 重新加载当前文档以应用新的渲染引擎
            if (pdfFile != null) {
                recycle();
                load(pdfFile.getDocumentSource(), pdfFile.getPassword());
            }
        }
    }
    
    @Override
    protected void onDetachedFromWindow() {
        EventBus.getDefault().unregister(this);
        // ... 现有代码 ...
    }
    
    // 修改渲染方法,根据标志选择渲染引擎
    private void renderPage(PagePart part) {
        if (useNewRenderingEngine) {
            renderPageWithNewEngine(part);
        } else {
            renderPageWithOldEngine(part);
        }
    }
    
    private void renderPageWithNewEngine(PagePart part) {
        // 新的渲染实现,例如使用改进的缓存策略和渲染算法
        try {
            long startTime = System.currentTimeMillis();
            
            // 1. 检查是否有缓存的优化版本
            Bitmap cachedBitmap = optimizedCacheManager.get(part);
            if (cachedBitmap != null) {
                part.setRenderedBitmap(cachedBitmap);
                onBitmapRendered(part);
                return;
            }
            
            // 2. 使用新的渲染参数
            PdfiumCoreRenderOptions options = new PdfiumCoreRenderOptions.Builder()
                .setAnnotationRendering(annotationRendering)
                .setBestQuality(bestQuality)
                .setOptimizeForSpeed(true)
                .build();
                
            // 3. 执行渲染
            Bitmap bitmap = pdfiumCore.renderPageOptimized(
                pdfFile.getDocument(),
                part.getPage(),
                options
            );
            
            // 4. 缓存渲染结果
            optimizedCacheManager.put(part, bitmap);
            
            part.setRenderedBitmap(bitmap);
            onBitmapRendered(part);
            
            // 记录性能指标用于A/B测试
            long renderTime = System.currentTimeMillis() - startTime;
            FeatureUsageTracker.trackRenderTime(
                part.getPage(), 
                renderTime, 
                true // 新引擎标记
            );
            
        } catch (Exception e) {
            onPageError(new PageRenderingException(part.getPage(), e));
        }
    }
    
    private void renderPageWithOldEngine(PagePart part) {
        // 原有的渲染实现
        // ... 现有代码 ...
        
        // 记录性能指标
        long renderTime = System.currentTimeMillis() - startTime;
        FeatureUsageTracker.trackRenderTime(
            part.getPage(), 
            renderTime, 
            false // 旧引擎标记
        );
    }
    
    // ... 现有代码 ...
}

2.2 添加功能使用跟踪

创建FeatureUsageTracker类记录功能使用情况和性能指标:

public class FeatureUsageTracker {
    private static final String PREFS_NAME = "feature_usage_tracker";
    
    public static void trackRenderTime(int page, long durationMs, boolean isNewEngine) {
        // 仅对启用了新功能的用户进行跟踪
        if (isNewEngine && !FeatureFlagManager.getInstance()
                .isEnabled(PdfFeatureFlags.RENDER_OPTIMIZATION)) {
            return;
        }
        
        try {
            // 本地存储性能数据
            SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
            String key = "render_time_" + (isNewEngine ? "new" : "old") + "_page_" + page;
            
            List<Long> times = new ArrayList<>();
            String json = prefs.getString(key, "[]");
            Type type = new TypeToken<List<Long>>(){}.getType();
            times = new Gson().fromJson(json, type);
            
            times.add(durationMs);
            
            // 限制列表大小,只保留最近的100条记录
            if (times.size() > 100) {
                times = times.subList(times.size() - 100, times.size());
            }
            
            prefs.edit().putString(key, new Gson().toJson(times)).apply();
            
            // 每收集10条记录就发送一次统计
            if (times.size() % 10 == 0) {
                sendRenderStats(isNewEngine, page, times);
            }
            
        } catch (Exception e) {
            Log.e("FeatureUsageTracker", "Error tracking render time", e);
        }
    }
    
    private static void sendRenderStats(boolean isNewEngine, int page, List<Long> times) {
        // 仅在WiFi环境下发送统计数据
        ConnectivityManager cm = (ConnectivityManager) getContext()
            .getSystemService(Context.CONNECTIVITY_SERVICE);
        NetworkInfo info = cm.getActiveNetworkInfo();
        
        if (info == null || !info.isConnected() || info.getType() != ConnectivityManager.TYPE_WIFI) {
            return;
        }
        
        // 计算统计数据
        long sum = 0;
        long min = Long.MAX_VALUE;
        long max = Long.MIN_VALUE;
        
        for (long time : times) {
            sum += time;
            min = Math.min(min, time);
            max = Math.max(max, time);
        }
        
        double avg = sum / (double) times.size();
        
        // 创建统计事件
        RenderStatsEvent event = new RenderStatsEvent();
        event.setEngineType(isNewEngine ? "new" : "old");
        event.setPage(page);
        event.setSampleSize(times.size());
        event.setMinTime(min);
        event.setMaxTime(max);
        event.setAvgTime(avg);
        event.setUserId(UserManager.getInstance().getCurrentUser().getId());
        event.setAppVersion(BuildConfig.VERSION_NAME);
        event.setFeatureFlag(PdfFeatureFlags.RENDER_OPTIMIZATION);
        
        // 发送到分析服务器
        new AsyncTask<RenderStatsEvent, Void, Void>() {
            @Override
            protected Void doInBackground(RenderStatsEvent... events) {
                try {
                    HttpURLConnection connection = (HttpURLConnection) 
                        new URL("https://your-analytics-server.com/render-stats").openConnection();
                    connection.setRequestMethod("POST");
                    connection.setDoOutput(true);
                    connection.setRequestProperty("Content-Type", "application/json");
                    
                    OutputStream os = connection.getOutputStream();
                    os.write(new Gson().toJson(events[0]).getBytes("UTF-8"));
                    os.flush();
                    os.close();
                    
                    connection.getResponseCode(); // 触发请求
                } catch (Exception e) {
                    Log.e("FeatureUsageTracker", "Failed to send stats", e);
                }
                return null;
            }
        }.execute(event);
    }
    
    // ... 其他跟踪方法 ...
    
    private static Context getContext() {
        return MyApplication.getInstance().getApplicationContext();
    }
}

实现步骤3:创建灰度发布控制面板

为了方便产品和运营人员管理功能标志,我们需要一个控制面板。这个面板可以是一个独立的Activity,仅对内部用户或管理员可见。

3.1 功能标志控制面板实现

public class FeatureFlagsControlPanel extends AppCompatActivity {
    private RecyclerView flagsRecyclerView;
    private FlagsAdapter adapter;
    private List<FlagViewModel> flagViewModels = new ArrayList<>();
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_feature_flags_control_panel);
        
        // 检查用户权限,仅允许管理员访问
        if (!UserManager.getInstance().getCurrentUser().isAdmin()) {
            finish();
            return;
        }
        
        flagsRecyclerView = findViewById(R.id.flags_recycler_view);
        flagsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
        adapter = new FlagsAdapter(flagViewModels);
        flagsRecyclerView.setAdapter(adapter);
        
        loadFlags();
        
        // 刷新按钮
        findViewById(R.id.refresh_button).setOnClickListener(v -> loadFlags());
        
        // 保存按钮
        findViewById(R.id.save_button).setOnClickListener(v -> saveFlags());
    }
    
    private void loadFlags() {
        Map<String, Flag> flags = FeatureFlagManager.getInstance().getAllFlags();
        flagViewModels.clear();
        
        for (Flag flag : flags.values()) {
            flagViewModels.add(new FlagViewModel(flag));
        }
        
        adapter.notifyDataSetChanged();
    }
    
    private void saveFlags() {
        Map<String, Flag> updatedFlags = new HashMap<>();
        for (FlagViewModel vm : flagViewModels) {
            updatedFlags.put(vm.getKey(), vm.toFlag());
        }
        
        FeatureFlagManager.getInstance().updateFlags(new Gson().toJson(updatedFlags));
        Toast.makeText(this, "Flags updated", Toast.LENGTH_SHORT).show();
    }
    
    public static class FlagViewModel {
        private String key;
        private boolean enabled;
        private String ruleType;
        private String ruleOperator;
        private String ruleValue;
        
        public FlagViewModel(Flag flag) {
            this.key = flag.getKey();
            this.enabled = flag.isEnabled();
            
            if (!flag.getRules().isEmpty()) {
                Rule firstRule = flag.getRules().get(0);
                this.ruleType = firstRule.getType();
                this.ruleOperator = firstRule.getOperator();
                this.ruleValue = firstRule.getValue();
            } else {
                this.ruleType = "percentage";
                this.ruleOperator = "lt";
                this.ruleValue = "100";
            }
        }
        
        public Flag toFlag() {
            List<Rule> rules = new ArrayList<>();
            rules.add(new Rule(ruleType, ruleOperator, ruleValue));
            return new Flag(key, enabled, rules);
        }
        
        // getter和setter方法
        public String getKey() { return key; }
        public void setKey(String key) { this.key = key; }
        public boolean isEnabled() { return enabled; }
        public void setEnabled(boolean enabled) { this.enabled = enabled; }
        public String getRuleType() { return ruleType; }
        public void setRuleType(String ruleType) { this.ruleType = ruleType; }
        public String getRuleOperator() { return ruleOperator; }
        public void setRuleOperator(String ruleOperator) { this.ruleOperator = ruleOperator; }
        public String getRuleValue() { return ruleValue; }
        public void setRuleValue(String ruleValue) { this.ruleValue = ruleValue; }
    }
    
    public class FlagsAdapter extends RecyclerView.Adapter<FlagViewHolder> {
        private List<FlagViewModel> items;
        
        public FlagsAdapter(List<FlagViewModel> items) {
            this.items = items;
        }
        
        @Override
        public FlagViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_flag_control, parent, false);
            return new FlagViewHolder(view);
        }
        
        @Override
        public void onBindViewHolder(FlagViewHolder holder, int position) {
            holder.bind(items.get(position));
        }
        
        @Override
        public int getItemCount() {
            return items.size();
        }
    }
    
    public class FlagViewHolder extends RecyclerView.ViewHolder {
        private TextView keyTextView;
        private Switch enabledSwitch;
        private Spinner ruleTypeSpinner;
        private Spinner ruleOperatorSpinner;
        private EditText ruleValueEditText;
        
        public FlagViewHolder(View itemView) {
            super(itemView);
            keyTextView = itemView.findViewById(R.id.flag_key);
            enabledSwitch = itemView.findViewById(R.id.enabled_switch);
            ruleTypeSpinner = itemView.findViewById(R.id.rule_type_spinner);
            ruleOperatorSpinner = itemView.findViewById(R.id.rule_operator_spinner);
            ruleValueEditText = itemView.findViewById(R.id.rule_value_edittext);
            
            // 初始化下拉列表
            ArrayAdapter<CharSequence> typeAdapter = ArrayAdapter.createFromResource(
                itemView.getContext(),
                R.array.rule_types,
                android.R.layout.simple_spinner_item
            );
            typeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
            ruleTypeSpinner.setAdapter(typeAdapter);
            
            ArrayAdapter<CharSequence> operatorAdapter = ArrayAdapter.createFromResource(
                itemView.getContext(),
                R.array.rule_operators,
                android.R.layout.simple_spinner_item
            );
            operatorAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
            ruleOperatorSpinner.setAdapter(operatorAdapter);
        }
        
        public void bind(FlagViewModel vm) {
            keyTextView.setText(vm.getKey());
            enabledSwitch.setChecked(vm.isEnabled());
            
            // 设置规则类型
            String[] ruleTypes = itemView.getContext().getResources()
                .getStringArray(R.array.rule_types);
            for (int i = 0; i < ruleTypes.length; i++) {
                if (ruleTypes[i].equals(vm.getRuleType())) {
                    ruleTypeSpinner.setSelection(i);
                    break;
                }
            }
            
            // 设置规则操作符
            String[] operators = itemView.getContext().getResources()
                .getStringArray(R.array.rule_operators);
            for (int i = 0; i < operators.length; i++) {
                if (operators[i].equals(vm.getRuleOperator())) {
                    ruleOperatorSpinner.setSelection(i);
                    break;
                }
            }
            
            ruleValueEditText.setText(vm.getRuleValue());
            
            // 添加监听器保存更改
            enabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> 
                vm.setEnabled(isChecked));
                
            ruleTypeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
                @Override
                public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
                    vm.setRuleType(parent.getItemAtPosition(position).toString());
                }
                
                @Override
                public void onNothingSelected(AdapterView<?> parent) {}
            });
            
            ruleOperatorSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
                @Override
                public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
                    vm.setRuleOperator(parent.getItemAtPosition(position).toString());
                }
                
                @Override
                public void onNothingSelected(AdapterView<?> parent) {}
            });
            
            ruleValueEditText.addTextChangedListener(new TextWatcher() {
                @Override
                public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
                
                @Override
                public void onTextChanged(CharSequence s, int start, int before, int count) {}
                
                @Override
                public void afterTextChanged(Editable s) {
                    vm.setRuleValue(s.toString());
                }
            });
        }
    }
}

3.2 添加布局文件

创建item_flag_control.xml布局文件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/flag_key"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:textSize="16sp"
            android:textStyle="bold"/>

        <Switch
            android:id="@+id/enabled_switch"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>

    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:orientation="horizontal"
        android:gravity="center_vertical">

        <Spinner
            android:id="@+id/rule_type_spinner"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginEnd="8dp"/>

        <Spinner
            android:id="@+id/rule_operator_spinner"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginEnd="8dp"/>

        <EditText
            android:id="@+id/rule_value_edittext"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:hint="Value"/>

    </LinearLayout>

</LinearLayout>

创建activity_feature_flags_control_panel.xml布局文件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:padding="16dp">

        <Button
            android:id="@+id/refresh_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Refresh"/>

        <View
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"/>

        <Button
            android:id="@+id/save_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Save"/>

    </LinearLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/flags_recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</LinearLayout>

灰度发布流程与最佳实践

有了完整的技术实现后,我们还需要一套规范的灰度发布流程来确保新功能顺利上线。以下是建议的发布流程和最佳实践:

完整灰度发布流程

mermaid

灰度发布策略选择指南

发布策略适用场景实现方式优点缺点
百分比发布无特殊用户群体要求基于用户ID哈希的百分比规则简单易实现,分布均匀无法精确定向特定用户
用户白名单内部测试、VIP用户优先体验user_id规则匹配精确控制,便于收集特定反馈手动维护成本高
版本定向仅对特定版本开放version规则匹配可针对特定版本测试兼容性无法覆盖所有版本用户
设备定向针对特定设备型号优化device规则匹配可解决特定设备兼容性问题设备型号识别复杂

灰度发布监控指标

在灰度发布过程中,需要密切监控以下几类关键指标:

  1. 性能指标

    • 页面渲染时间(新旧引擎对比)
    • 内存占用情况
    • CPU使用率
    • 崩溃率和ANR率
  2. 用户体验指标

    • 页面加载成功率
    • 功能使用频率
    • 用户停留时间
    • 滑动流畅度(FPS)
  3. 业务指标

    • 文档打开次数
    • 平均阅读页数
    • 用户留存率
    • 付费转化率(如适用)

总结与展望

通过本文介绍的Feature Flags实现方案,我们成功为AndroidPdfViewer添加了灰度发布能力。这种方式可以让开发团队:

  1. 降低新功能发布风险,快速回滚有问题的功能
  2. 针对不同用户群体测试功能效果,收集精准反馈
  3. 实现渐进式发布,平滑过渡到新版本
  4. 建立数据驱动的产品迭代流程

未来,我们可以进一步扩展这个系统,添加更多高级功能:

  • 更复杂的目标规则系统,支持多规则组合和用户分群
  • 自动灰度发布流程,基于监控指标自动调整发布范围
  • 更完善的A/B测试框架,支持多版本并行测试
  • 实时用户反馈收集和分析系统

灰度发布不是一次性的技术实现,而是一种持续的产品开发理念。通过不断优化发布流程和工具链,我们可以让AndroidPdfViewer更加稳定、可靠,为用户提供更好的PDF阅读体验。

参考资料

  1. Martin Fowler的Feature Flags文章系列
  2. Google Play的分阶段推出功能
  3. Android应用性能优化最佳实践
  4. 《持续交付》一书的发布策略章节

关于作者

本文由AndroidPdfViewer开发团队撰写,旨在分享灰度发布在开源项目中的实践经验。如有任何问题或建议,请通过项目GitHub仓库提交issue。

如果你觉得本文有帮助,请点赞、收藏并关注我们,以获取更多AndroidPdfViewer的技术文章和使用技巧!

【免费下载链接】AndroidPdfViewer Android view for displaying PDFs rendered with PdfiumAndroid 【免费下载链接】AndroidPdfViewer 项目地址: https://gitcode.com/gh_mirrors/an/AndroidPdfViewer

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值