Android-Firebase快速解决合规问题第3篇,解决FirebaseCrashlytics库违规网络请求、获取AndroidId问题

系列文章

背景

安全合规检测,app启动,未征得用户同意,发现以下问题:

  1. 发起了网络请求https://firebase-settings.crashlytics.com/
  2. 违规收集android id

FirebaseCrashlytics在国内依然可以使用,手机不开谷歌服务,不开vpn也能正常收集到数据。

强烈推荐优先阅读,后续会有相关知识

依赖环境

demo的环境如下,只是为了演示firebase出现的问题,本篇文章基于Flutter作为开发语言,实现了demo演示问题,原生库、RN库同理可以解决问题。
android版本:
build.gradle

compileSdkVersion 31
minSdkVersion 21
targetSdkVersion 31

futter版本:Flutter 2.10.5
pubspec.yaml

firebase_core: 1.10.0
firebase_messaging: 10.0.0
firebase_crashlytics: 2.2.0
firebase_analytics: 9.1.0
firebase_performance: 0.7.0+3
dio_firebase_performance: ^0.3.0

解决方案

解决方案支持原生、flutter库,RN库。
先把解决方案放在最上面,不想看详细的过程就直接复制粘贴使用吧。
在AndroidManifest.xml中接入以下代码,重点在tools:node=“remove”,将这个provider移除掉。

<!--禁用FirebaseApp初始化        -->
<provider
    android:name="com.google.firebase.provider.FirebaseInitProvider"
    android:authorities="${applicationId}.firebaseinitprovider"
    android:exported="false"
    tools:node="remove"/>

再参考android原生、Flutter、RN通过代码在合适的时机进行初始化即可。
Flutter初始化文档

依照上篇文章Android-Firebase快速解决合规问题第2篇,解决firebase_performance库获取软件安装列表的行为,我们应该是找到FirebaseCrashlytics对应的provider,再禁用掉,为什么这里直接把整个Firebase的核心初始化都禁用了。

原因:FirebaseCrashlytics没有提供的对应的provider,FirebaseCrashlytics的初始化是依赖核心FirebaseApp初始化的时候去加载FirebaseCrashlytics,所以我们只能从根源上解决。

堆栈信息

app启动,未征得用户同意,安全合规检测堆栈信息。

问题1:网络请求

通过抓包可以看到以下结果

GET /spi/v2/platforms/android/
Host: firebase-settings.crashlytics.com 

问题2:获取android id

-> android.provider.Settings$Secure.getStringForUser()
-> android.provider.Settings$Secure.getString(Settings.java:5275)
-> com.google.firebase.crashlytics.internal.common.CommonUtils.isEmulator(CommonUtils.java:1)
...
-> com.google.firebase.crashlytics.internal.common.BackgroundPriorityRunnable.run(BackgroundPriorityRunnable.java:2)
-> java.lang.Thread.run(Thread.java:919)

分析问题

问题1:可以知道在app启动的时候就会发生网络流量请求,是在FirebaseCrashlytics库中发生。
问题2:从栈顶可以知道,最终是触发了getString方法获取android id,但是找不到从哪里调起来的,那就根据上一篇文章学到的方法,运行debug模式尝试定位问题。
在这里插入图片描述
可以看到,通过debug模式,得出的堆栈信息比第三方检测机构给出的信息更全。那就一层一层找,最顶上就是调用获取android id的方法,往下(往父级调用)寻找,doOpenSession()这个方法,再往下就找不到了,就在当前文件中寻找调用的地方。
在这里插入图片描述找到了openSession() -> doOpenSession()这条链路,这时需要借助github的在线vscode查询源码,谁调用了openSession() 。经过层层向上级调用者查询,得到以下调用链:
FirebaseCrashlytics.init()初始化 -> CrashlyticsCore类中onPreExecute -> CrashlyticsController类中enableExceptionHandling() -> openSession() -> CommonUtils.isEmulator() -> getString 获取android id。

解决方案:以上两个问题,可以结合在一起,可能都与FirebaseCrashlytics的初始化有关,尝试延迟FirebaseCrashlytics的初始化。

解决问题思路

从Flutter代码层面,延迟初始化

既然使用了Flutter开发,那就按照上面的分析,找到对应位置延时调用初始化函数即可。

FirebaseCrashlytics crashlytics = FirebaseCrashlytics.instance;

经过检查,FirebaseCrashlytics引发的两个问题并没有解决。

Flutter层延迟初始化不行,那就尝试在原生层解决问题。

从原生层面排查

想办法延迟FirebaseCrashlytics初始化,依然是以下2个技巧:

  1. debug断点方式
  2. 利用github自带的在线vscode查看源码

依葫芦画瓢

在这里插入图片描述
从上一篇文章可以知道,我们只要把<provider> 中的内容去掉再通过代码的方式初始化,但发现FirebaseCrashlytics库中没有<provider> ,只有一个<service>标签,<service>表示注册该模块到FirebaseApp中,这样该模块才能使用,所以是不能去掉。

去掉的结果:


<service
    android:name="com.google.firebase.components.ComponentDiscoveryService">
    <meta-data
        android:name="com.google.firebase.components:com.google.firebase.crashlyshlyticsRegistrar"
        android:value="com.google.firebase.components.ComponentRegistrar"
        tools:node="remove"/>
</service> 

在使用FirebaseCrashlytics会报错,找不到该模块。

java.lang.NullPointerException: FirebaseCrashlytics component is not present., null, null)

可以推测,在FirebaseCrashlytics库中,不是通过<provider> 标签的方式来初始化。

解决问题

先找到FirebaseCrashlytics.init()被调用的地方
在这里插入图片描述
可以看到FirebaseCrashlytics.init()的初始化,是在CrashlyticsRegistrar类中,并且这个类,就是上面通过<service>标签注册到FirebaseApp中的类。
在这里插入图片描述
在这里插入图片描述
再结合断点的时机,可以看到FirebaseApp.initializeApp()初始化后会走到FirebaseCrashlytics.init(),那就是说FirebaseCrashlytics依赖FirebaseApp的初始化。得到这个结论,那就尝试解决问题,把FirebaseApp的初始化延后,查看FirebaseApp相关源码
在这里插入图片描述
在这里插入图片描述

看到这个就说明FirebaseApp的初始化,依赖<provider> ,老规则移除该provider

<application>
	<!--禁用FirebaseApp初始化        -->
	<provider
	    android:name="com.google.firebase.provider.FirebaseInitProvider"
	    android:authorities="${applicationId}.firebaseinitprovider"
	    android:exported="false"
	    tools:node="remove"
	/>
</>
// Flutter
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform,);

然后在Flutter层,在合适的时机通过代码初始化FirebaseApp,顺势就会初始化FirebaseCrashlytics库,2个问题解决。
参考Flutter官方初始化方法

精益求精

上面通过逆向推导的方式,找到获取Android id的调用链,最终确定延迟FirebaseApp初始化,进而延迟FirebaseCrashlytics的初始化,解决FirebaseCrashlytics合规的2个问题。

但在解决问题中确有3个疑问:

  1. 为什么FirebaseCrashlytics没有使用<provider> 引导初始化,依然会在FirebaseApp.initializeApp()中被初始化。
  2. 网络请求是在FirebaseCrashlytics初始化发生的,那具体是什么时候发生、怎么发生的?
  3. FirebaseCrashlytics是如何违规收集android id?

1.FirebaseApp执行FirebaseCrashlytics.init()的过程

从FirebaseApp.initializeApp()开始分析,这个是Firebase的核心。主要分析FirebaseApp与FirebaseCrashlytics的初始化关系。

FirebaseApp

public static final @NonNull String DEFAULT_APP_NAME = "[DEFAULT]";
public static FirebaseApp initializeApp(@NonNull Context context) {
    
    
    synchronized (LOCK) {
    
    
      if (INSTANCES.containsKey(DEFAULT_APP_NAME)) {
    
    
        return getInstance();
      }
      FirebaseOptions firebaseOptions = FirebaseOptions.fromResource(context);
      if (firebaseOptions == null) {
    
    
      	return initializeApp(context, firebaseOptions);
    }
}
@NonNull
public static FirebaseApp initializeApp(
    @NonNull Context context, @NonNull FirebaseOptions options, @NonNull String name) {
    
    
  final FirebaseApp firebaseApp;
  Context applicationContext;
  if (context.getApplicationContext() == null) {
    
    
    applicationContext = context;
  } else {
    
    
    applicationContext = context.getApplicationContext();
  }
  synchronized (LOCK) {
    
    
  	// 重点1
    firebaseApp = new FirebaseApp(applicationContext, normalizedName, options);
    INSTANCES.put(normalizedName, firebaseApp);
  }

	//重点2
  firebaseApp.initializeAllApis();
  return firebaseApp;
}

可以看到会先从缓存Map里面取初始化过的FirebaseApp,没有的话就会用默认的名字创建一个再缓存起来。

protected FirebaseApp(Context applicationContext, String name, FirebaseOptions options) {
    
    

	// 解析所有<service>标签的模块
    List<Provider<ComponentRegistrar>> registrars =
        ComponentDiscovery.forContext(applicationContext, ComponentDiscoveryService.class)
            .discoverLazy();

	// 执行
    componentRuntime =
        ComponentRuntime.builder(UI_EXECUTOR)
            .addLazyComponentRegistrars(registrars)
            .addComponentRegistrar(new FirebaseCommonRegistrar())
            .addComponent(Component.of(applicationContext, Context.class))
            .addComponent(Component.of(this, FirebaseApp.class))
            .addComponent(Component.of(options, FirebaseOptions.class))
            .build();
}

registrars会把有<service> 标签的模块解析出来,存在registrars里面。

// AndroidManifest.xml
<service android:name="com.google.firebase.components.ComponentDiscoveryService" android:exported="false">
    <meta-data android:name="com.google.firebase.components:com.google.firebase.crashlytics.CrashlyticsRegistrar"
        android:value="com.google.firebase.components.ComponentRegistrar" />
</service>

ComponentRuntime

componentRuntime执行build()有条件的初始化模块

private void discoverComponents(List<Component<?>> componentsToAdd) {
    
    
    List<Runnable> runAfterDiscovery = new ArrayList<>();
    synchronized (this) {
    
    
      Iterator<Provider<ComponentRegistrar>> iterator = unprocessedRegistrarProviders.iterator();
      while (iterator.hasNext()) {
    
    
        Provider<ComponentRegistrar> provider = iterator.next();
        
          ComponentRegistrar registrar = provider.get();
          if (registrar != null) {
    
    
          	// 这里就是调用了getComponents(),将模块的初始化方法添加进去
            componentsToAdd.addAll(registrar.getComponents());
            iterator.remove();
          }
     
      }

      for (Component<?> component : componentsToAdd) {
    
    
        Lazy<?> lazy =
            new Lazy<>(
                () ->
                    component
                        .getFactory()
                        .create(new RestrictedComponentContainer(component, this)));

        components.put(component, lazy);
      }
    }
    ...
    maybeInitializeEagerComponents();
}

componentsToAdd.addAll(registrar.getComponents()); 就会回调每个模块的getComponents(),而getComponents()中通过factory字段声明了FirebaseCrashlytics的初始化方法是哪个。
在这里插入图片描述

回到FirebaseApp

firebaseApp.initializeAllApis();

private void initializeAllApis() {
    
    
	componentRuntime.initializeEagerComponents(isDefaultApp());
}

public static final @NonNull String DEFAULT_APP_NAME = "[DEFAULT]";
public boolean isDefaultApp() {
    
    
    return DEFAULT_APP_NAME.equals(getName());
}

initializeEagerComponents表示需要立刻初始化的模块,比如FirebaseCrashlytics。
isDefaultApp()用来判断该FirebaseApp是否通过默认初始化得到的实例。

回到ComponentRuntime

private void doInitializeEagerComponents(
      Map<Component<?>, Provider<?>> componentsToInitialize, boolean isDefaultApp) {
    
    
    for (Map.Entry<Component<?>, Provider<?>> entry : componentsToInitialize.entrySet()) {
    
    
      Component<?> component = entry.getKey();
      Provider<?> provider = entry.getValue();
	// 关键这里判断,是否需要立刻初始化模块
      if (component.isAlwaysEager() || (component.isEagerInDefaultApp() && isDefaultApp)) {
    
    
      // 在这里会执行CrashlyticsRegistrar.buildCrashlytics()方法
        provider.get();
      }
    }

    eventBus.enablePublishingAndFlushPending();
}

所以可以知道,provider.get();实际就是调用了以下方法,获取factory字段并执行相应模块的初始化方法。

component
.getFactory()
.create(new RestrictedComponentContainer(component, this)))

结合CrashlyticsRegistrar的配置,可以看到buildCrashlytics被执行后,开始初始化FirebaseCrashlytics。
在这里插入图片描述
CrashlyticsRegistrar中.eagerInDefaultApp()方法很关键,这个就是标记了该模块被注册后,执行FirebaseCrashlytics模块的初始化。所以不需要像其他模块使用<provider> 引导初始化。

完整链路

FirebaseApp.initializeApp() -> new FirebaseApp() 解析<service> 标签的模块,完成注册 ->
firebaseApp.initializeAllApis()加载需要立刻初始化的模块 -> CrashlyticsRegistrar buildCrashlytics()构建Crashlytics模块 -> FirebaseCrashlytics.init()初始化

总结:

  1. AndroidManifest.xml中使用<service>注册了模块信息。
<service android:name="com.google.firebase.components.ComponentDiscoveryService" android:exported="false">
            <meta-data android:name="com.google.firebase.components:com.google.firebase.crashlytics.CrashlyticsRegistrar"
                android:value="com.google.firebase.components.ComponentRegistrar" />
</service>
  1. 在对应的类CrashlyticsRegistrar中,字段factory告知FirebaseCrashlytics库的初始化方法,eagerInDefaultApp()方法标记该库在FirebaseApp初始化完成后,立刻执行该模块的初始化。
public class CrashlyticsRegistrar implements ComponentRegistrar {
    
    
  @Override
  public List<Component<?>> getComponents() {
    
    
    return Arrays.asList(
        Component.builder(FirebaseCrashlytics.class)
            .factory(this::buildCrashlytics)
            .eagerInDefaultApp()
            .build(),
        LibraryVersionComponent.create("fire-cls", BuildConfig.VERSION_NAME));
  }
}
  1. 这一段流程,就说明了,FirebaseCrashlytics没有使用<provider> 引导初始化,依然能够自动执行初始化的原因。

2.FirebaseCrashlytics初始化发起网络请求

GET /spi/v2/platforms/android/
Host: firebase-settings.crashlytics.com 

网络请求是在FirebaseCrashlytics初始化发生的,那具体是什么时候发生、怎么发生的?

static FirebaseCrashlytics init(@NonNull FirebaseApp app, @NonNull FirebaseInstallationsApi firebaseInstallationsApi, @NonNull Deferred<CrashlyticsNativeComponent> nativeComponent, @NonNull Deferred<AnalyticsConnector> analyticsConnector) {
    
    
	// 创建
	final SettingsController settingsController = SettingsController.create(context, googleAppId, idManager, new HttpRequestFactory(), appData.versionCode, appData.versionName, arbiter);
	
	// 网络请求获取数据
	settingsController.loadSettingsData(threadPoolExecutor).continueWith(threadPoolExecutor, ...);
}

创建一个SettingsController,配置管理类,用来获取后台配置

SettingsController

private static final String SETTINGS_URL_FORMAT = "https://firebase-settings.crashlytics.com/spi/v2/platforms/android/gmp/%s/settings";

public static SettingsController create(Context context, ...) {
    
    
    final String settingsUrl = String.format(Locale.US, SETTINGS_URL_FORMAT, googleAppId);
    // 网络请求类
    final SettingsSpiCall settingsSpiCall =
        new DefaultSettingsSpiCall(settingsUrl, httpRequestFactory);
}

设置配置

DefaultSettingsSpiCall

将上面的url保存起来,

DefaultSettingsSpiCall(String url, HttpRequestFactory requestFactory, Logger logger) {
    
    
        if (url == null) {
    
    
            throw new IllegalArgumentException("url must not be null.");
        } else {
    
    
            this.logger = logger;
            this.requestFactory = requestFactory;
            this.url = url;
        }
}

// 设置请求头
protected HttpGetRequest createHttpGetRequest(Map<String, String> queryParams) {
    
    
        HttpGetRequest httpRequest = this.requestFactory.buildHttpGetRequest(this.url, queryParams);
        return httpRequest.header("User-Agent", "Crashlytics Android SDK/" + CrashlyticsCore.getVersion()).header("X-CRASHLYTICS-DEVELOPER-TOKEN", "470fa2b4ae81cd56ecbcda9735803434cec591fa");
}

// 请求
public JSONObject invoke(SettingsRequest requestData, boolean dataCollectionToken) {
    
    
	HttpGetRequest httpRequest = this.createHttpGetRequest(queryParams);
    httpRequest = this.applyHeadersTo(httpRequest, requestData);
    // 发出请求
    HttpResponse httpResponse = httpRequest.execute();
    toReturn = this.handleResponse(httpResponse);
}

网络请求时机

FirebaseCrashlytics init
开始网络请求配置,通过settingsController

settingsController.loadSettingsData(threadPoolExecutor).continueWith(threadPoolExecutor, ...);

SettingsController

settingsController.loadSettingsData(threadPoolExecutor).continueWith(threadPoolExecutor, ...){
    
    
	JSONObject settingsJson = SettingsController.this.settingsSpiCall.invoke(SettingsController.this.settingsRequest, true);
}

走到settingsSpiCall专门负责网络请求类,发起网络请求。得到的配置信息,会解析、缓存起来,第二次启动app就直接从缓存中获取,不发起网络请求。返回的消息如下:

{
    
    
    "settings_version":3,
    "cache_duration":86400,
    "features":{
    
    
        "collect_logged_exceptions":true,
        "collect_reports":true,
        "collect_analytics":false,
        "prompt_enabled":false,
        "push_enabled":false,
        "firebase_crashlytics_enabled":false,
        "collect_anrs":true,
        "collect_metric_kit":false
    },
    "app":{
    
    
        "status":"activated",
        "update_required":false,
        "report_upload_variant":2,
        "native_report_upload_variant":2
    },
    "fabric":{
    
    
        "org_id":"xxx",
        "bundle_id":"xxx"
    },
    "on_demand_upload_rate_per_minute":10,
    "on_demand_backoff_base":1.2,
    "on_demand_backoff_step_duration_seconds":60
}

完整链路

FirebaseCrashlytics init() -> settingsController.loadSettingsData()加载配置数据 -> settingsSpiCall.invoke()调用接口,发生网络请求。

总结:发起网络的请求的目的,是为了获取后台配置,时机是在FirebaseCrashlytics.init()初始化时。所以延迟FirebaseCrashlytics初始化就能解决问题。

3.FirebaseCrashlytics是如何获取android id?

static FirebaseCrashlytics init(@NonNull FirebaseApp app, @NonNull FirebaseInstallationsApi firebaseInstallationsApi, @NonNull Deferred<CrashlyticsNativeComponent> nativeComponent, @NonNull Deferred<AnalyticsConnector> analyticsConnector) {
    
    
	// 构建线程池服务
	ExecutorService crashHandlerExecutor = ExecutorUtils.buildSingleThreadExecutorService("Crashlytics Exception Handler");
	final CrashlyticsCore core = new CrashlyticsCore(app, ..., crashHandlerExecutor);
        
	...
	final boolean finishCoreInBackground = core.onPreExecute(appData, settingsController);
}
public static ExecutorService buildSingleThreadExecutorService(String name) {
    
    
        ThreadFactory threadFactory = getNamedThreadFactory(name);
        // 创建的线程使用BackgroundPriorityRunnable类执行
        ExecutorService executor = newSingleThreadExecutor(threadFactory, new DiscardPolicy());
        addDelayedShutdownHook(name, executor);
        return executor;
    }

创建线程执行工具

public static ThreadFactory getNamedThreadFactory(final String threadNameTemplate) {
    
    
        final AtomicLong count = new AtomicLong(1L);
        return new ThreadFactory() {
    
    
            public Thread newThread(final Runnable runnable) {
    
    
            	// 线程工厂类Executors.defaultThreadFactory()
                Thread thread = Executors.defaultThreadFactory().newThread(new BackgroundPriorityRunnable() {
    
    
                    public void onRun() {
    
    
                        runnable.run();
                    }
                });
                thread.setName(threadNameTemplate + count.getAndIncrement());
                return thread;
            }
        };
    }

crashHandlerExecutor是单一的线程池 ,作为参数传递给CrashlyticsCore。

CrashlyticsCore

public CrashlyticsCore(FirebaseApp app, ...,ExecutorService crashHandlerExecutor) {
    
    
        this.app = app;
        this.crashHandlerExecutor = crashHandlerExecutor;
        this.backgroundWorker = new CrashlyticsBackgroundWorker(crashHandlerExecutor);
}

将创建好的线程池,传递进来并保存起来。交给CrashlyticsBackgroundWorker去管理线程池。接着再init()中执行core.onPreExecute()

public boolean onPreExecute(AppData appData, SettingsDataProvider settingsProvider) {
    
    
	// 创建CrashlyticsController,并把上面的backgroundWorker传递过去
	this.controller = new CrashlyticsController(this.context, this.backgroundWorker, ...);

	this.controller.enableExceptionHandling(Thread.getDefaultUncaughtExceptionHandler(), settingsProvider);
}

创建CrashlyticsController,并把上面的backgroundWorker传递过去。
settingsProvider其实就是FirebaseCrashlytics.init()中的settingsController用来管理配置信息、从网络获取配置。

CrashlyticsBackgroundWorker类

后面会用到,这里不用细看,只是做个记录。

class CrashlyticsBackgroundWorker {
    
    
    private final Executor executor;
    private Task<Void> tail = Tasks.forResult((Object)null);
    private final Object tailLock = new Object();
    private final ThreadLocal<Boolean> isExecutorThread = new ThreadLocal();
    
    public CrashlyticsBackgroundWorker(Executor executor) {
    
    
        this.executor = executor;
        executor.execute(new Runnable() {
    
    
            public void run() {
    
    
                CrashlyticsBackgroundWorker.this.isExecutorThread.set(true);
            }
        });
    }
    
	public <T> Task<T> submit(Callable<T> callable) {
    
    
        synchronized(this.tailLock) {
    
    
            Task<T> toReturn = this.tail.continueWith(this.executor, this.newContinuation(callable));
            this.tail = this.ignoreResult(toReturn);
            return toReturn;
        }
    }
}

CrashlyticsController

将传递过来的CrashlyticsBackgroundWorker保存起来,并作为执行线程的工具。

CrashlyticsController(Context context, CrashlyticsBackgroundWorker backgroundWorker, ...) {
    
    
        this.context = context;
        this.backgroundWorker = backgroundWorker;
}
void enableExceptionHandling(UncaughtExceptionHandler defaultHandler, SettingsDataProvider settingsProvider) {
    
    
	// 关键步骤
    this.openSession();
    ...
}

void openSession() {
    
    
	this.backgroundWorker.submit(new Callable<Void>() {
    
    
		public Void call() throws Exception {
    
    
           CrashlyticsController.this.doOpenSession();
           return null;
        }
    });
}

给线程池添加一个任务,用来执行doOpenSession()

private void doOpenSession() {
    
    
	...
    OsData osData = createOsData(this.getContext());
    ...
}

private static OsData createOsData(Context context) {
    
    
        return OsData.create(VERSION.RELEASE, VERSION.CODENAME, CommonUtils.isRooted(context));
}

CommonUtils

public static boolean isRooted(Context context) {
    
    
	boolean isEmulator = isEmulator(context);
}

public static boolean isEmulator(Context context) {
    
    
    String androidId = Secure.getString(context.getContentResolver(), "android_id");
    return Build.PRODUCT.contains("sdk") || Build.HARDWARE.contains("goldfish") || Build.HARDWARE.contains("ranchu") || androidId == null;
}

最终在isEmulator()方法中调用Secure.getString获取android id。作用是用来判断当前设备是否模拟器。

完整链路

/com/google/firebase/crashlytics/FirebaseCrashlytics.java, FirebaseCrashlytics.init()方法中new CrashlyticsCore(),并把crashHandlerExecutor线程池作为参数传递给CrashlyticsCore,在CrashlyticsCore中又把crashHandlerExecutor传递给CrashlyticsBackgroundWorker。init()方法结束前CrashlyticsCore.java core.onPreExecute(appData, settingsController) -> 在内部初始化了CrashlyticsController(),并调用了controller.enableExceptionHandling() -> openSession() 方法发起了一个后台任务backgroundWorker(CrashlyticsBackgroundWorker) ,在后台任务中执行doOpenSession() -> createOsData() -> CommonUtils.isRooted -> isEmulator() -> 获取android id

总结:这样就找到获取android id的地方,也就是在FirebaseCrashlytics.init()初始化时,有这样操作。

相关文档

猜你喜欢

转载自blog.csdn.net/ZZB_Bin/article/details/125574650
今日推荐