• 【HMS Core】构建SplitBill应用集成多个HMS Core服务,助力您更好的了解华为生态组成


    一、介绍

    Duration: 3:00

    总览

    通过构建本次的SplitBill应用,您可以更好地了解华为生态的组成部分,包括认证服务、云存储和云数据库等Serverless服务。此外您还可以了解如何使用近距离数据通信服务的Nearby Connection功能分享文件。无需使用现金,SplitBill应用能够实现用户与其他任意用户共同支付账单。

    您将建立什么

    在本次的Codelab中,您将建立一款SplitBill应用,并使用到华为认证服务、Network Kit、近距离数据通信服务、云数据库和云存储的接口。该应用为用户提供端到端的群收款服务。使用该应用,多位用户可以组成一个群,共同完成账单支付。

    您将学到什么

    • 使用认证服务登录应用(华为账号和手机号认证方式)

    • 创建群。

    • 添加成员到群。

    • 添加账单。

    • 上传用户头像至云存储和在云数据库中更新头像。

    • 使用近距离通信服务分享账单文件。

    二、您需要什么

    Duration: 2:00

    开发环境

    • 一台装有Windows10操作系统的台式或笔记本电脑。

    • 搭载HMC Core(APK)5.0.0.300或以上版本的华为手机。

    • 通过验证华为账号

    设备要求

    一部用于测试的安卓手机或模拟器。

    三、集成准备

    Duration: 10:00

    在集成SDK之前,您需要完成以下准备:

    • 在AppGallery Connect上创建应用。

    • 创建一个安卓项目。

    • 生成签名证书。

    • 生成签名证书指纹。

    • 配置签名证书指纹。

    • 添加应用包名,保存配置文件。

    • 在项目级build.gradle文件中添加AppGallery Connect插件和Maven仓。

    • 在Android Studio中配置签名证书。

    详情请见HUWEI HMS Core集成准备

    说明:在上述准备工作前您需要注册成为一名开发者。

    四、开通服务

    Duration: 4:00

    首先,您需要在AppGallery Connect上启用HMS Core的相关服务。启用前,请完成以下准备工作:

    1、登录AppGallery Connect,点击“项目设置”中“API管理”页签,开通如下服务的API权限。

    • 认证服务(华为账号认证方式)

    • 云数据库

    • 云存储

    • 近距离通信服务

    • Network Kit

    cke_22898.png

    cke_24882.png

    cke_27208.png

    cke_29271.png

    说明:以上API权限默认已开通。如未开通,请手动开通。

    2、在弹出的页面设置数据处理位置。

    cke_37905.png

    五、集成SDK

    Duration: 4:00

    您需要将云数据库SDK集成到您的Android Studio项目中。

    1、登录AppGallery Connect,点击“我的项目”。

    2、点击您的项目,在顶部的应用下拉列表中选择应用。

    3、点击“项目设置”>“常规”。在“应用”区域,下载agconnect-services.json文件。

    cke_64778.png

    4、将agconnect-services.json文件复制到您的项目中。

    cke_71966.png

    5、在Android Studio中,打开项目级build.gradle文件,前往allprojects > repositories,在buildscript的repositories部分配置Maven仓地址。

    cke_80468.png

    cke_84522.png

    6、在buildscript的dependencies部分配置AppGallery Connect插件。

    1. buildscript {
    2. dependencies {
    3. classpath 'com.huawei.agconnect:agcp:'
    4. }
    5. }

    7、在应用级build.gradle文件中添加AppGallery Connect插件。

    apply plugin: 'com.huawei.agconnect'

    8、(可选)在Application类中的onCreate方法里添加初始化代码。

    1. if (AGConnectInstance.getInstance() == null) {
    2. AGConnectInstance.initialize(getApplicationContext());
    3. }

    9、在应用级build.gradle文件里的dependencies部分添加所需依赖路径。

    1. implementation 'com.huawei.agconnect:agconnect-auth:'
    2. implementation 'com.huawei.hms:hwid:'
    3. implementation 'com.huawei.hms:nearby: '
    4. implementation"com.huawei.agconnect:agconnect-storage:"
    5. implementation'com.huawei.agconnect:agconnect-cloud-database:'

    六、设计UI

    Duration: 5:00

    创建以下界面:登录、建群、群列表、添加账单、账单和收支标签页、账单详情、账单分享详情和收到的账单文件列表。

    用户登录界面

    cke_136790.png

    群列表和建群界面

    cke_147222.pngcke_150144.pngcke_154068.pngcke_159027.png

    七、前提准备

    Duration: 5:00

    认证服务

    认证服务SDK能够让您快速便捷地在您的应用上实现用户注册和登录功能。

    1. 登录AppGallery Connect,点击“我的项目”。

    2. 点击您的项目。

    3. 选择“构建”,单击“认证服务”。若您首次使用认证服务,点击“立即开通”。

    4. 单击“认证方式”,在“操作”栏中启用华为账号认证方式。

    cke_185653.png

    Cloud DB

    使用云数据库服务,您需要启用云数据库,创建存储区和云数据库对象所需字段。

    1、登录AppGallery Connect,点击“我的项目”。

    2、点击您的项目。

    3、选择“构建”,点击“云数据库”。若您首次使用云数据库,点击“立即开通”。

    4、在弹出页面设置数据处理位置。

    5、单击“新增”,进入创建对象类型页面。

    cke_218809.png

    6、设置对象类型,单击“下一步”。

    7、单击“+新增字段”添加字段,单击“下一步”。

    8、添加所需索引。

    9、设置各角色权限。

    cke_248030.png

    10、单击“确定”。返回对象类型列表,查看已创建的对象类型。

    11、单击“导出”。

    cke_282257.png

    12、选择文件格式,此处选择文件类型为Java,文件格式为Android。输入包名,点击“确定”。对象类型文件将被作为zip文件下载

    13、提取zip中的文件至项目的model包里。

    1)点击“存储区”页签。

    2)单击“新增”,进入创建存储区页面。

    cke_345998.png

    云存储

    使用云存储服务,您需要启用云存储,并在开发前完成下述操作。

    1、启用云存储后,创建存储实例,单击“下一步”。

    2、创建安全策略,控制是否允许未经认证的用户访问存储。单击“完成”。

    cke_365965.png

    3、完成上述操作后,您即可使用云存储服务。

    八、实现功能

    Duration: 15:00

    完成准备工作后,集成认证服务、云数据库、云存储、Network Kit和近距离通信服务到您的应用中。

    在已设计好的登录页面添加如下代码,实现华为账号登录按钮。

    1. activityAuthBinding.tvGetcode.setOnClickListener(this);
    2. activityAuthBinding.btnHuaweiId.setOnClickListener(this);
    3. HuaweiIdAuthParamsHelper huaweiIdAuthParamsHelper = new HuaweiIdAuthParamsHelper(HuaweiIdAuthParams.DEFAULT_AUTH_REQUEST_PARAM);
    4. scopeList = new ArrayList<>();
    5. scopeList.add(new Scope(HwIDConstant.SCOPE.ACCOUNT_BASEPROFILE));
    6. scopeList.add(new Scope(HwIDConstant.SCOPE.SCOPE_ACCOUNT_EMAIL));
    7. scopeList.add(new Scope(HwIDConstant.SCOPE.SCOPE_MOBILE_NUMBER));
    8. scopeList.add(new Scope(HwIDConstant.SCOPE.SCOPE_ACCOUNT_PROFILE));
    9. huaweiIdAuthParamsHelper.setScopeList(scopeList);
    10. HuaweiIdAuthParams authParams = huaweiIdAuthParamsHelper.setAccessToken().setMobileNumber().createParams();
    11. service = HuaweiIdAuthManager.getService(this, authParams);
    12. Button Click:
    13. loginViewModel.signInWithHuaweiId(AuthActivity.this, service).observe(AuthActivity.this, new Observer<SignInResult>() {
    14. @Override
    15. public void onChanged(SignInResult user) {
    16. activityAuthBinding.authProgressBar.setVisibility(View.VISIBLE);
    17. progress();
    18. loginSuccess (user);
    19. }
    20. });

    实现OnActivityResult。

    1. huaweiSignIn.launch(service.getSignInIntent());
    2. ActivityResultLauncher<Intent> huaweiSignIn = AuthActivity.authActivity.registerForActivityResult(
    3. new ActivityResultContracts.StartActivityForResult(),
    4. new ActivityResultCallback<ActivityResult>() {
    5. @Override
    6. public void onActivityResult(ActivityResult result) {
    7. if (result.getResultCode() == Activity.RESULT_OK) {
    8. Task<AuthAccount> authAccountTask = AccountAuthManager.parseAuthResultFromIntent(result.getData());
    9. if (authAccountTask.isSuccessful()) {
    10. AuthAccount authAccount = authAccountTask.getResult();
    11. AGConnectAuthCredential credential = HwIdAuthProvider.credentialWithToken(authAccount.getAccessToken());
    12. AGConnectAuth.getInstance().signIn(credential).addOnSuccessListener(new OnSuccessListener<SignInResult>() {
    13. @Override
    14. public void onSuccess(SignInResult signInResult) {
    15. // onSuccess
    16. Common.showToast("log in", AuthActivity.authActivity);
    17. authenticatedUserMutableLiveData.setValue(signInResult);
    18. }
    19. })
    20. .addOnFailureListener(new OnFailureListener() {
    21. @Override
    22. public void onFailure(Exception e) {
    23. Common.showToast("fail", AuthActivity.authActivity);
    24. }
    25. });
    26. }
    27. }
    28. }
    29. });

    创建wrapper类,用于初始化云数据库存储区,插入新用户和验证已知用户等数据库操作。

    1. public class CloudDBZoneWrapper { private static final String TAG = "CloudDBZoneWrapper"; private static final String DB_NAME = "SplitBillSampleApp"; private AGConnectCloudDB mCloudDB; private CloudDBZone mCloudDBZone; private CloudDBZoneConfig mConfig; /**
    2. SplitBillApplication
    3. * Get instance of Cloud DB zone wrapper to initiate cloud DB * * @return mCloudDBZoneWrapper */ public CloudDBZoneWrapper getCloudDBZoneWrapper() { if (mCloudDBZoneWrapper != null) { return mCloudDBZoneWrapper; } mCloudDBZoneWrapper = new CloudDBZoneWrapper(); return mCloudDBZoneWrapper; }
    4. /** * Get CloudDB task to open AGConnectCloudDB */ public Task<CloudDBZone> openCloudDBZoneV2() { mConfig = new CloudDBZoneConfig(DB_NAME, CloudDBZoneConfig.CloudDBZoneSyncProperty.CLOUDDBZONE_CLOUD_CACHE, CloudDBZoneConfig.CloudDBZoneAccessProperty.CLOUDDBZONE_PUBLIC); mConfig.setPersistenceEnabled(true); Task<CloudDBZone> openDBZoneTask = mCloudDB.openCloudDBZone2(mConfig, true); return openDBZoneTask; }
    5. /**
    6. * 调用AGConnectCloudDB.closeCloudDBZone
    7. */
    8. public void closeCloudDBZone() {
    9. try {
    10. mRegister.remove();
    11. mCloudDB.closeCloudDBZone(mCloudDBZone);
    12. } catch (AGConnectCloudDBException e) {
    13. Log.w(TAG, "CloudDBZone: " + e.getMessage());
    14. }
    15. }
    16. /**
    17. * 云数据库中插入好友数据
    18. */
    19. public MutableLiveData<Boolean> upsertExpenseData(Friends friends) {
    20. if (mCloudDBZone == null) {
    21. Log.w(TAG, "CloudDBZone is null, try re-open it");
    22. }
    23. Task<Integer> upsertTask = mCloudDBZone.executeUpsert(friends);
    24. upsertTask.addOnSuccessListener(new OnSuccessListener<Integer>() {
    25. @Override
    26. public void onSuccess(Integer cloudDBZoneResult) {
    27. friendsUpdateSuccess.postValue(true);
    28. }
    29. }).addOnFailureListener(new OnFailureListener() {
    30. @Override
    31. public void onFailure(Exception e) {
    32. friendsUpdateSuccess.postValue(false);
    33. }
    34. });
    35. return friendsUpdateSuccess;
    36. }
    37. /**
    38. * 云数据库中获取好友列表
    39. */
    40. public MutableLiveData<Integer> getFriendsIdLiveData() {
    41. CloudDBZoneQuery<Friends> snapshotQuery = CloudDBZoneQuery.where(Friends.class);
    42. Task<Long> countQueryTask = mCloudDBZone.executeCountQuery(snapshotQuery, FriendsEditFields.CONTACT_ID,
    43. CloudDBZoneQuery.CloudDBZoneQueryPolicy.POLICY_QUERY_FROM_CLOUD_ONLY);
    44. countQueryTask.addOnSuccessListener(new OnSuccessListener<Long>() {
    45. @Override
    46. public void onSuccess(Long aLong) {
    47. Log.i(TAG, "The total number of groups is " + aLong);
    48. friendsIdLiveData.postValue((int) (aLong + 1));
    49. }
    50. }).addOnFailureListener(new OnFailureListener() {
    51. @Override
    52. public void onFailure(Exception e) {
    53. Log.w(TAG, "Count query is failed: " + Log.getStackTraceString(e));
    54. }
    55. });
    56. return friendsIdLiveData;
    57. }
    58. /**
    59. * 云数据库中获取好友列表
    60. * 从数据库中添加,获取群或账单数据使用相似方法。
    61. */
    62. private void insertGroupDataInDB(Group group){
    63. groupViewModel.upsertGroupData(group).observe(getActivity(), new Observer<Boolean>() {
    64. @Override
    65. public void onChanged(Boolean aBoolean) {
    66. if (aBoolean.equals(true)) {
    67. progressStats = true;
    68. Toast.makeText(getContext(), getString(R.string.add_group_success) + " " + group.getName(), Toast.LENGTH_LONG).show();
    69. Navigation.findNavController(fragmentCreateGroupBinding.getRoot()).navigate(R.id.navigation_activity);
    70. } else {
    71. Toast.makeText(getContext(), getString(R.string.add_group_failed), Toast.LENGTH_LONG).show();
    72. progressStats = false;
    73. }
    74. }
    75. });
    76. }
    77. 添加账单数据:
    78. private void addExpenseData() {
    79. Expense expense = new Expense();
    80. String strExpenseName = expenseName.getText().toString();
    81. String strPaidBy = spinner.getSelectedItem().toString();
    82. String strExpenseAmount = expenseDesc.getText().toString();
    83. expense.setId(0);
    84. expense.setName(strExpenseName);
    85. expense.setAmount(Float.parseFloat((strExpenseAmount) + ".00"));
    86. expense.setPaid_user_id(0);
    87. expense.setStatus(1);
    88. expenseViewModel.upsertExpenseData(expense).observe(getActivity(), new Observer<Boolean>() {
    89. @Override
    90. public void onChanged(Boolean aBoolean) {
    91. if (aBoolean.equals(true)) {
    92. Toast.makeText(getContext(), getString(R.string.add_expense_success) + " " + strExpenseName, Toast.LENGTH_LONG).show();
    93. //TODO : back key
    94. } else {
    95. Toast.makeText(getContext(), getString(R.string.add_expense_failed), Toast.LENGTH_LONG).show();
    96. }
    97. }
    98. });
    99. }

    数据插入完成后,接着使用近距离通信服务提供的类。

    1. NearbyAgent:
    2. /*
    3. * 华为技术有限公司版权所有 保留一切权利,
    4. 授权于Apache License 2.0版本(以下简称“许可证”)。
    5. 使用此许可证时,须遵循其规定。
    6. 您可以通过以下途径获取许可证的副本:
    7. http://www.apache.org/licenses/LICENSE-2.0
    8. 除非得到适用法律或书面同意,
    9. 许可证许可范围内所提供的软件均按“现状”提供,
    10. 而不做任何明示或暗示的保证。
    11. 关于许可证中特定语言的权限和限制,
    12. 请参见许可证。
    13. */
    14. package com.huawei.codelabs.splitbill.ui.main.helper;
    15. import android.Manifest;
    16. import android.app.Dialog;
    17. import android.content.Context;
    18. import android.content.Intent;
    19. import android.graphics.Bitmap;
    20. import android.graphics.Canvas;
    21. import android.graphics.Color;
    22. import android.graphics.Paint;
    23. import android.graphics.Typeface;
    24. import android.graphics.pdf.PdfDocument;
    25. import android.net.Uri;
    26. import android.os.Environment;
    27. import android.util.Log;
    28. import android.view.View;
    29. import androidx.core.app.ActivityCompat;
    30. import androidx.documentfile.provider.DocumentFile;
    31. import androidx.fragment.app.FragmentActivity;
    32. import androidx.recyclerview.widget.LinearLayoutManager;
    33. import com.huawei.codelabs.splitbill.R;
    34. import com.huawei.codelabs.splitbill.databinding.FragmentAccountBinding;
    35. import com.huawei.codelabs.splitbill.databinding.FragmentFileDetailsBinding;
    36. import com.huawei.codelabs.splitbill.databinding.FragmentSendExpenseDetailsBinding;
    37. import com.huawei.codelabs.splitbill.ui.main.activities.MainActivity;
    38. import com.huawei.codelabs.splitbill.ui.main.adapter.DeviceAdapter;
    39. import com.huawei.codelabs.splitbill.ui.main.adapter.FilesAdapter;
    40. import com.huawei.codelabs.splitbill.ui.main.adapter.FriendsListAdapter;
    41. import com.huawei.codelabs.splitbill.ui.main.models.Device;
    42. import com.huawei.codelabs.splitbill.ui.main.models.Files;
    43. import com.huawei.hms.hmsscankit.ScanUtil;
    44. import com.huawei.hms.hmsscankit.WriterException;
    45. import com.huawei.hms.ml.scan.HmsBuildBitmapOption;
    46. import com.huawei.hms.ml.scan.HmsScan;
    47. import com.huawei.hms.ml.scan.HmsScanAnalyzerOptions;
    48. import com.huawei.hms.nearby.Nearby;
    49. import com.huawei.hms.nearby.StatusCode;
    50. import com.huawei.hms.nearby.discovery.BroadcastOption;
    51. import com.huawei.hms.nearby.discovery.ConnectCallback;
    52. import com.huawei.hms.nearby.discovery.ConnectInfo;
    53. import com.huawei.hms.nearby.discovery.ConnectResult;
    54. import com.huawei.hms.nearby.discovery.DiscoveryEngine;
    55. import com.huawei.hms.nearby.discovery.Policy;
    56. import com.huawei.hms.nearby.discovery.ScanEndpointCallback;
    57. import com.huawei.hms.nearby.discovery.ScanEndpointInfo;
    58. import com.huawei.hms.nearby.discovery.ScanOption;
    59. import com.huawei.hms.nearby.transfer.Data;
    60. import com.huawei.hms.nearby.transfer.DataCallback;
    61. import com.huawei.hms.nearby.transfer.TransferEngine;
    62. import com.huawei.hms.nearby.transfer.TransferStateUpdate;
    63. import java.io.File;
    64. import java.io.FileNotFoundException;
    65. import java.io.FileOutputStream;
    66. import java.io.IOException;
    67. import java.io.InputStream;
    68. import java.io.OutputStream;
    69. import java.nio.charset.StandardCharsets;
    70. import java.util.ArrayList;
    71. import java.util.List;
    72. import static java.nio.charset.StandardCharsets.UTF_8;
    73. public class NearbyAgent {
    74. public static final int REQUEST_CODE_SCAN_ONE = 0X01;
    75. private static final String[] REQUIRED_PERMISSIONS =
    76. new String[]{Manifest.permission.ACCESS_COARSE_LOCATION,
    77. Manifest.permission.ACCESS_FINE_LOCATION,
    78. Manifest.permission.ACCESS_WIFI_STATE,
    79. Manifest.permission.CHANGE_WIFI_STATE,
    80. Manifest.permission.BLUETOOTH,
    81. Manifest.permission.BLUETOOTH_ADMIN,
    82. Manifest.permission.READ_EXTERNAL_STORAGE,
    83. Manifest.permission.WRITE_EXTERNAL_STORAGE,
    84. Manifest.permission.CAMERA};
    85. private static final int REQUEST_CODE_REQUIRED_PERMISSIONS = 1;
    86. private final String TAG = "Nearby_Agent";
    87. private final String mFileServiceId = "NearbyAgentFileService";
    88. private final String mEndpointName = android.os.Build.DEVICE;
    89. int lineYAxis = 350;
    90. FragmentAccountBinding fragmentAccountBinding;
    91. FragmentSendExpenseDetailsBinding fragmentSendExpenseDetailsBinding;
    92. FragmentFileDetailsBinding fragmentFileDetailsBinding;
    93. ArrayList<Files> filesArrayList;
    94. FilesAdapter groupAdapter;
    95. private MainActivity mContext = null;
    96. private TransferEngine mTransferEngine = null;
    97. private DiscoveryEngine mDiscoveryEngine = null;
    98. private List<File> mFiles = new ArrayList<>();
    99. private String mRemoteEndpointId;
    100. private String mRemoteEndpointName;
    101. private String mScanInfo;
    102. private final ScanEndpointCallback mDiscCb =
    103. new ScanEndpointCallback() {
    104. @Override
    105. public void onFound(String endpointId, ScanEndpointInfo discoveryEndpointInfo) {
    106. if (discoveryEndpointInfo.getName().equals(mScanInfo)) {
    107. Log.d(TAG, "Found endpoint:" + discoveryEndpointInfo.getName() + ". Connecting.");
    108. mDiscoveryEngine.requestConnect(mEndpointName, endpointId, mConnCbRcver);
    109. }
    110. }
    111. @Override
    112. public void onLost(String endpointId) {
    113. Log.d(TAG, "Lost endpoint.");
    114. }
    115. };
    116. private String mRcvedFilename = null;
    117. private Bitmap mResultImage;
    118. private String mFileName;
    119. private long mStartTime = 0;
    120. private float mSpeed = 60;
    121. private String mSpeedStr = "60";
    122. private boolean isTransfer = false;
    123. private final DataCallback mDataCbSender =
    124. new DataCallback() {
    125. @Override
    126. public void onReceived(String endpointId, Data data) {
    127. if (data.getType() == Data.Type.BYTES) {
    128. String msg = new String(data.asBytes(), UTF_8);
    129. if (msg.equals("Receive Success")) {
    130. Log.d(TAG, "Received ACK. Send next.");
    131. sendOneFile();
    132. }
    133. }
    134. }
    135. @Override
    136. public void onTransferUpdate(String string, TransferStateUpdate update) {
    137. if (update.getStatus() == TransferStateUpdate.Status.TRANSFER_STATE_SUCCESS) {
    138. } else if (update.getStatus() == TransferStateUpdate.Status.TRANSFER_STATE_IN_PROGRESS) {
    139. showProgressSpeedSender(update);
    140. } else if (update.getStatus() == TransferStateUpdate.Status.TRANSFER_STATE_FAILURE) {
    141. Log.d(TAG, "Transfer failed.");
    142. } else {
    143. Log.d(TAG, "Transfer cancelled.");
    144. }
    145. }
    146. };
    147. private final ConnectCallback mConnCbRcver =
    148. new ConnectCallback() {
    149. @Override
    150. public void onEstablish(String endpointId, ConnectInfo connectionInfo) {
    151. Log.d(TAG, "Accept connection.");
    152. mRemoteEndpointName = connectionInfo.getEndpointName();
    153. mRemoteEndpointId = endpointId;
    154. mDiscoveryEngine.acceptConnect(endpointId, mDataCbRcver);
    155. }
    156. @Override
    157. public void onResult(String endpointId, ConnectResult result) {
    158. if (result.getStatus().getStatusCode() == StatusCode.STATUS_SUCCESS) {
    159. Log.d(TAG, "Connection Established. Stop Discovery.");
    160. mDiscoveryEngine.stopBroadcasting();
    161. mDiscoveryEngine.stopScan();
    162. fragmentFileDetailsBinding.tvMainDesc.setText("Connected.");
    163. }
    164. }
    165. @Override
    166. public void onDisconnected(String endpointId) {
    167. Log.d(TAG, "Disconnected.");
    168. if (isTransfer == true) {
    169. fragmentSendExpenseDetailsBinding.tvMainDesc.setVisibility(View.GONE);
    170. fragmentSendExpenseDetailsBinding.tvMainDesc.setText("Connection lost.");
    171. }
    172. }
    173. };
    174. private Data incomingFile = null;
    175. private final DataCallback mDataCbRcver =
    176. new DataCallback() {
    177. @Override
    178. public void onReceived(String endpointId, Data data) {
    179. if (data.getType() == Data.Type.BYTES) {
    180. String msg = new String(data.asBytes(), UTF_8);
    181. mRcvedFilename = msg;
    182. Log.d(TAG, "received filename: " + mRcvedFilename);
    183. isTransfer = true;
    184. fragmentFileDetailsBinding.tvMainDesc.setText(new StringBuilder("Receiving file ").append(mRcvedFilename).append(" from ").append(mRemoteEndpointName + ".").toString());
    185. fragmentFileDetailsBinding.pbMainDownload.setVisibility(View.VISIBLE);
    186. } else if (data.getType() == Data.Type.FILE) {
    187. incomingFile = data;
    188. } else {
    189. Log.d(TAG, "received stream. ");
    190. }
    191. }
    192. public File getLastModified(String directoryFilePath)
    193. {
    194. File directory = new File(directoryFilePath);
    195. File[] files = directory.listFiles(File::isFile);
    196. long lastModifiedTime = Long.MIN_VALUE;
    197. File chosenFile = null;
    198. if (files != null)
    199. {
    200. for (File file : files)
    201. {
    202. if (file.lastModified() > lastModifiedTime)
    203. {
    204. chosenFile = file;
    205. lastModifiedTime = file.lastModified();
    206. }
    207. }
    208. }
    209. return chosenFile;
    210. }
    211. @Override
    212. public void onTransferUpdate(String string, TransferStateUpdate update) {
    213. if (update.getStatus() == TransferStateUpdate.Status.TRANSFER_STATE_SUCCESS) {
    214. } else if (update.getStatus() == TransferStateUpdate.Status.TRANSFER_STATE_IN_PROGRESS) {
    215. showProgressSpeedReceiver(update);
    216. if (update.getBytesTransferred() == update.getTotalBytes()) {
    217. Log.d(TAG, "File transfer done. Rename File.");
    218. renameFile();
    219. Log.d(TAG, "Send Ack.");
    220. fragmentFileDetailsBinding.tvMainDesc.setText(new StringBuilder("Transfer success. Speed: ").append(mSpeedStr).append("MB/s. \nView the File at /Sdcard/Download/Nearby"));
    221. mTransferEngine.sendData(mRemoteEndpointId, Data.fromBytes("Receive Success".getBytes(StandardCharsets.UTF_8)));
    222. isTransfer = false;
    223. Files files = new Files();
    224. File file= getLastModified(Environment.getExternalStorageDirectory().getPath() + Constants.DOWNLOAD_PATH);
    225. files.setFileName(file.getName());
    226. files.setFilePath(new File(file.getAbsolutePath()));
    227. filesArrayList.add(files);
    228. groupAdapter.notifyDataSetChanged();
    229. }
    230. } else if (update.getStatus() == TransferStateUpdate.Status.TRANSFER_STATE_FAILURE) {
    231. Log.d(TAG, "Transfer failed.");
    232. } else {
    233. Log.d(TAG, "Transfer cancelled.");
    234. }
    235. }
    236. };
    237. private ArrayList<Device> deviceList;
    238. private final ConnectCallback mConnCbSender =
    239. new ConnectCallback() {
    240. @Override
    241. public void onEstablish(String endpointId, ConnectInfo connectionInfo) {
    242. Log.d(TAG, "Accept connection.");
    243. mDiscoveryEngine.acceptConnect(endpointId, mDataCbSender);
    244. fragmentSendExpenseDetailsBinding.rcDevice.setHasFixedSize(true);
    245. fragmentSendExpenseDetailsBinding.rcDevice.setLayoutManager(new LinearLayoutManager(mContext));
    246. Device device = new Device();
    247. device.setDeviceName(connectionInfo.getEndpointName());
    248. deviceList.add(device);
    249. DeviceAdapter deviceAdapter = new DeviceAdapter(deviceList);
    250. fragmentSendExpenseDetailsBinding.rcDevice.setAdapter(deviceAdapter);
    251. deviceAdapter.notifyDataSetChanged();
    252. mRemoteEndpointName = connectionInfo.getEndpointName();
    253. mRemoteEndpointId = endpointId;
    254. }
    255. @Override
    256. public void onResult(String endpointId, ConnectResult result) {
    257. if (result.getStatus().getStatusCode() == StatusCode.STATUS_SUCCESS) {
    258. Log.d(TAG, "Connection Established. Stop discovery. Start to send file.");
    259. mDiscoveryEngine.stopScan();
    260. mDiscoveryEngine.stopBroadcasting();
    261. sendOneFile();
    262. fragmentSendExpenseDetailsBinding.barcodeImage.setVisibility(View.GONE);
    263. fragmentSendExpenseDetailsBinding.tvMainDesc.setText(new StringBuilder("MB/s. \nView the File at /Sdcard/Download/Nearby").append(mFileName).append(" to ").append(mRemoteEndpointName).append("."));
    264. fragmentSendExpenseDetailsBinding.pbMainDownload.setVisibility(View.GONE);
    265. }
    266. }
    267. @Override
    268. public void onDisconnected(String endpointId) {
    269. Log.d(TAG, "Disconnected.");
    270. if (isTransfer == true) {
    271. fragmentSendExpenseDetailsBinding.pbMainDownload.setVisibility(View.GONE);
    272. fragmentSendExpenseDetailsBinding.tvMainDesc.setText("Connection lost.");
    273. }
    274. }
    275. };
    276. public NearbyAgent(MainActivity context) {
    277. mContext = context;
    278. mDiscoveryEngine = Nearby.getDiscoveryEngine(context);
    279. deviceList = new ArrayList<>();
    280. mTransferEngine = Nearby.getTransferEngine(context);
    281. if (context instanceof MainActivity) {
    282. ActivityCompat.requestPermissions(context, REQUIRED_PERMISSIONS, REQUEST_CODE_REQUIRED_PERMISSIONS);
    283. }
    284. }
    285. public NearbyAgent(MainActivity context, FragmentAccountBinding fragmentAccountBinding) {
    286. this.fragmentAccountBinding = fragmentAccountBinding;
    287. this.mContext = context;
    288. mDiscoveryEngine = Nearby.getDiscoveryEngine(context);
    289. mTransferEngine = Nearby.getTransferEngine(context);
    290. if (context instanceof MainActivity) {
    291. ActivityCompat.requestPermissions(context, REQUIRED_PERMISSIONS, REQUEST_CODE_REQUIRED_PERMISSIONS);
    292. }
    293. }
    294. public static String getFileRealNameFromUri(Context context, Uri fileUri) {
    295. if (context == null || fileUri == null) {
    296. return Constants.UnknownFile;
    297. }
    298. DocumentFile documentFile = DocumentFile.fromSingleUri(context, fileUri);
    299. if (documentFile == null) {
    300. return Constants.UnknownFile;
    301. }
    302. return documentFile.getName();
    303. }
    304. private void showProgressSpeedSender(TransferStateUpdate update) {
    305. long transferredBytes = update.getBytesTransferred();
    306. long totalBytes = update.getTotalBytes();
    307. long curTime = System.currentTimeMillis();
    308. Log.d(TAG, "Transfer in progress. Transferred Bytes: "
    309. + transferredBytes + " Total Bytes: " + totalBytes);
    310. fragmentSendExpenseDetailsBinding.pbMainDownload.setProgress((int) (transferredBytes * 100 / totalBytes));
    311. if (mStartTime == 0) {
    312. mStartTime = curTime;
    313. }
    314. if (curTime != mStartTime) {
    315. mSpeed = ((float) transferredBytes) / ((float) (curTime - mStartTime)) / 1000;
    316. java.text.DecimalFormat myformat = new java.text.DecimalFormat("0.00");
    317. mSpeedStr = myformat.format(mSpeed);
    318. fragmentSendExpenseDetailsBinding.tvMainDesc.setText(new StringBuilder("Transfer in Progress. Speed: ").append(mSpeedStr).append("MB/s."));
    319. }
    320. if (transferredBytes == totalBytes) {
    321. mStartTime = 0;
    322. }
    323. }
    324. public void loadScanCode(Bitmap mResultImage) {
    325. fragmentSendExpenseDetailsBinding.barcodeImage.setVisibility(View.VISIBLE);
    326. fragmentSendExpenseDetailsBinding.barcodeImage.setImageBitmap(mResultImage);
    327. }
    328. public File createPdf(List friendsUIList, Bitmap scaledImageBitmap, FragmentActivity activity) {
    329. //创建一个新的document。
    330. PdfDocument document = new PdfDocument();
    331. //创建页面描述。
    332. PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo.Builder(Constants.PAGEWIDTH, Constants.PAGEHEIGHT, 1).create();
    333. //开启一个页面。
    334. PdfDocument.Page page = document.startPage(pageInfo);
    335. Canvas canvas = page.getCanvas();
    336. Paint paint = new Paint();
    337. canvas.drawBitmap(scaledImageBitmap, 0, 0, paint);
    338. paint.setTextAlign(Paint.Align.CENTER);
    339. paint.setTextSize(50);
    340. paint.setColor(activity.getResources().getColor(android.R.color.black));
    341. paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.NORMAL));
    342. canvas.drawText("Invoice" + System.currentTimeMillis(), Constants.PAGEWIDTH / 2, 260, paint);
    343. paint.setStrokeWidth(2f);
    344. canvas.drawLine(Constants.PAGEWIDTH / 2, 300, 550, 300, paint);
    345. canvas.drawText("Amount" + " " + "Participants", Constants.PAGEWIDTH / 2, 400, paint);
    346. lineYAxis = 450;
    347. for (FriendsListAdapter.FriendsUI friendsUI : friendsUIList) {
    348. canvas.drawText(friendsUI.getFriendsName() + " " + friendsUI.getAmount() + "", Constants.PAGEWIDTH / 2, Constants.LINEYAXIS + 50, paint);
    349. lineYAxis = lineYAxis + 50;
    350. }
    351. document.finishPage(page);
    352. pageInfo = new PdfDocument.PageInfo.Builder(Constants.PAGEWIDTH, Constants.PAGEHEIGHT, 2).create();
    353. page = document.startPage(pageInfo);
    354. document.finishPage(page);
    355. //写入document内容。
    356. String directory_path = Environment.getExternalStorageDirectory().getPath() + Constants.CREATEINVOICE;
    357. File file = new File(directory_path);
    358. if (!file.exists()) {
    359. file.mkdirs();
    360. }
    361. String targetPdf = new StringBuilder().append(directory_path).append("invoice").append(System.currentTimeMillis()).append(".pdf").toString();
    362. File filePath = new File(targetPdf);
    363. try {
    364. document.writeTo(new FileOutputStream(filePath));
    365. } catch (IOException e) {
    366. Log.e("main", "error " + e.toString());
    367. }
    368. //关闭document。
    369. document.close();
    370. return filePath;
    371. }
    372. public void sendFile(File file, FragmentSendExpenseDetailsBinding fragmentSendExpenseDetailsBinding) {
    373. this.fragmentSendExpenseDetailsBinding = fragmentSendExpenseDetailsBinding;
    374. init();
    375. mFiles.add(file);
    376. sendFilesInner();
    377. }
    378. public void sendFiles(List files) {
    379. init();
    380. mFiles = files;
    381. sendFilesInner();
    382. }
    383. public void sendFolder(File folder) {
    384. init();
    385. File[] subFile = folder.listFiles();
    386. for (int i = 0; i < subFile.length; i++) {
    387. if (!subFile[i].isDirectory()) {
    388. mFiles.add(subFile[i]);
    389. Log.d(TAG, "Travel folder: " + subFile[i].getName());
    390. }
    391. }
    392. sendFilesInner();
    393. }
    394. private void sendFilesInner() {
    395. /* 生成bitmap */
    396. try {
    397. //生成barcode。
    398. HmsBuildBitmapOption options = new HmsBuildBitmapOption.Creator().setBitmapMargin(1).setBitmapColor(Color.BLACK).setBitmapBackgroundColor(Color.WHITE).create();
    399. mResultImage = ScanUtil.buildBitmap(mEndpointName, HmsScan.QRCODE_SCAN_TYPE, Constants.BARCODE_SIZE, Constants.BARCODE_SIZE, options);
    400. loadScanCode(mResultImage);
    401. } catch (WriterException e) {
    402. Log.e(TAG, e.toString());
    403. }
    404. /* 开始广播 */
    405. BroadcastOption.Builder advBuilder = new BroadcastOption.Builder();
    406. advBuilder.setPolicy(Policy.POLICY_P2P);
    407. mDiscoveryEngine.startBroadcasting(mEndpointName, mFileServiceId, mConnCbSender, advBuilder.build());
    408. Log.d(TAG, "Start Broadcasting.");
    409. }
    410. public void receiveFile(FragmentFileDetailsBinding fragmentFileDetailsBinding, ArrayList filesArrayList, FilesAdapter groupAdapter) {
    411. /* 扫描bitmap */
    412. this.fragmentFileDetailsBinding = fragmentFileDetailsBinding;
    413. this.filesArrayList=filesArrayList;
    414. this.groupAdapter=groupAdapter;
    415. Log.d("TAG", "start");
    416. init();
    417. HmsScanAnalyzerOptions options = new HmsScanAnalyzerOptions.Creator().setHmsScanTypes(HmsScan.QRCODE_SCAN_TYPE, HmsScan.DATAMATRIX_SCAN_TYPE).create();
    418. ScanUtil.startScan(mContext, REQUEST_CODE_SCAN_ONE, options);
    419. Log.d("TAG", "Sent");
    420. }
    421. public void onScanResult(Intent data) {
    422. if (data == null) {
    423. Log.d("TAG", "fail");
    424. fragmentFileDetailsBinding.tvMainDesc.setText("Scan Failed.");
    425. return;
    426. }
    427. /* 保存设备名称*/
    428. HmsScan obj = data.getParcelableExtra(ScanUtil.RESULT);
    429. mScanInfo = obj.getOriginalValue();
    430. /* 开始扫描*/
    431. ScanOption.Builder scanBuilder = new ScanOption.Builder();
    432. scanBuilder.setPolicy(Policy.POLICY_P2P);
    433. mDiscoveryEngine.startScan(mFileServiceId, mDiscCb, scanBuilder.build());
    434. Log.d(TAG, "Start Scan.");
    435. fragmentFileDetailsBinding.tvMainDesc.setText(new StringBuilder().append("Connecting to ").append(mScanInfo).append("..."));
    436. }
    437. private void sendOneFile() {
    438. Data filenameMsg = null;
    439. Data filePayload = null;
    440. isTransfer = true;
    441. Log.d(TAG, "Left " + mFiles.size() + " Files to send.");
    442. if (mFiles.isEmpty()) {
    443. Log.d(TAG, "All Files Done. Disconnect");
    444. fragmentSendExpenseDetailsBinding.tvMainDesc.setText(R.string.all_files_sent);
    445. fragmentSendExpenseDetailsBinding.pbMainDownload.setVisibility(View.GONE);
    446. fragmentSendExpenseDetailsBinding.tvHeading.setVisibility(View.GONE);
    447. mDiscoveryEngine.disconnectAll();
    448. isTransfer = false;
    449. return;
    450. }
    451. try {
    452. mFileName = mFiles.get(0).getName();
    453. filePayload = Data.fromFile(mFiles.get(0));
    454. mFiles.remove(0);
    455. } catch (FileNotFoundException e) {
    456. Log.e(TAG, "File not found", e);
    457. return;
    458. }
    459. filenameMsg = Data.fromBytes(mFileName.getBytes(StandardCharsets.UTF_8));
    460. Log.d(TAG, "Send filename: " + mFileName);
    461. mTransferEngine.sendData(mRemoteEndpointId, filenameMsg);
    462. Log.d(TAG, "Send Payload.");
    463. mTransferEngine.sendData(mRemoteEndpointId, filePayload);
    464. }
    465. private void renameFile() {
    466. if (incomingFile == null) {
    467. Log.d(TAG, "incomingFile is null");
    468. return;
    469. }
    470. File rawFile = incomingFile.asFile().asJavaFile();
    471. Log.d(TAG, "raw file: " + rawFile.getAbsolutePath());
    472. File targetFileName = new File(rawFile.getParentFile(), mRcvedFilename);
    473. Log.d(TAG, "rename to : " + targetFileName.getAbsolutePath());
    474. Uri uri = incomingFile.asFile().asUri();
    475. if (uri == null) {
    476. boolean result = rawFile.renameTo(targetFileName);
    477. if (!result) {
    478. Log.e(TAG, "rename failed");
    479. } else {
    480. Log.e(TAG, "rename Succeeded ");
    481. }
    482. } else {
    483. try {
    484. openStream(uri, targetFileName);
    485. } catch (IOException e) {
    486. Log.e(TAG, e.toString());
    487. } finally {
    488. delFile(uri, rawFile);
    489. }
    490. }
    491. }
    492. private void openStream(Uri uri, File targetFileName) throws IOException {
    493. InputStream in = mContext.getContentResolver().openInputStream(uri);
    494. Log.e(TAG, "open input stream successfuly");
    495. try {
    496. copyStream(in, new FileOutputStream(targetFileName));
    497. Log.e(TAG, "copyStream successfuly");
    498. } catch (IOException e) {
    499. Log.e(TAG, e.toString());
    500. } finally {
    501. in.close();
    502. }
    503. }
    504. private void copyStream(InputStream in, OutputStream out) throws IOException {
    505. try {
    506. byte[] buffer = new byte[1024];
    507. int read;
    508. while ((read = in.read(buffer)) != -1) {
    509. out.write(buffer, 0, read);
    510. }
    511. out.flush();
    512. } finally {
    513. out.close();
    514. }
    515. }
    516. private void delFile(Uri uri, File payloadfile) {
    517. //删除源文件。
    518. mContext.getContentResolver().delete(uri, null, null);
    519. if (!payloadfile.exists()) {
    520. Log.e(TAG, "delete original file by uri successfully");
    521. } else {
    522. Log.e(TAG, "delete original file by uri failed and try to delete it by File delete");
    523. payloadfile.delete();
    524. if (payloadfile.exists()) {
    525. Log.e(TAG, "fail to delete original file");
    526. } else {
    527. Log.e(TAG, "delete original file successfully");
    528. }
    529. }
    530. }
    531. private void showProgressSpeedReceiver(TransferStateUpdate update) {
    532. long transferredBytes = update.getBytesTransferred();
    533. long totalBytes = update.getTotalBytes();
    534. long curTime = System.currentTimeMillis();
    535. Log.d(TAG, "Transfer in progress. Transferred Bytes: "
    536. + transferredBytes + " Total Bytes: " + totalBytes);
    537. fragmentFileDetailsBinding.pbMainDownload.setProgress((int) (transferredBytes * 100 / totalBytes));
    538. if (mStartTime == 0) {
    539. mStartTime = curTime;
    540. }
    541. if (curTime != mStartTime) {
    542. mSpeed = ((float) transferredBytes) / ((float) (curTime - mStartTime)) / 1000;
    543. java.text.DecimalFormat myformat = new java.text.DecimalFormat("0.00");
    544. mSpeedStr = myformat.format(mSpeed);
    545. fragmentFileDetailsBinding.tvMainDesc.setText(new StringBuilder().append("Transfer in Progress. Speed: ").append(mSpeedStr).append("MB/s."));
    546. }
    547. if (transferredBytes == totalBytes) {
    548. mStartTime = 0;
    549. }
    550. }
    551. private void init() {
    552. if (fragmentSendExpenseDetailsBinding != null) {
    553. fragmentSendExpenseDetailsBinding.pbMainDownload.setProgress(0);
    554. fragmentSendExpenseDetailsBinding.pbMainDownload.setVisibility(View.GONE);
    555. fragmentSendExpenseDetailsBinding.tvMainDesc.setText("");
    556. fragmentSendExpenseDetailsBinding.barcodeImage.setVisibility(View.GONE);
    557. }
    558. mDiscoveryEngine.disconnectAll();
    559. mDiscoveryEngine.stopScan();
    560. mDiscoveryEngine.stopBroadcasting();
    561. mFiles.clear();
    562. }
    563. }
    564. 初始化用于MainActivityNearByAgent类:
    565. nearbyAgent = new NearbyAgent(this);
    566. SendExpenseDetailsFragment中发送文件码:
    567. ((MainActivity) getActivity()).nearbyAgent.sendFile(new File(getArguments().getString("mValues")), fragmentSendExpenseDetailsBinding);
    568. FileDetailsFragment中接收文件:
    569. ((MainActivity) getActivity()).nearbyAgent.receiveFile(fragmentFileDetailsBinding, filesArrayList, groupAdapter);
    570. MainActivity的onActivityResult中获取结果。
    571. @Override
    572. public void onActivityResult(int requestCode, int resultCode, Intent data) {
    573. switch (requestCode) {
    574. case NearbyAgent.REQUEST_CODE_SCAN_ONE:
    575. Log.d("data:", "1");
    576. nearbyAgent.onScanResult(data);
    577. break;
    578. default:
    579. break;
    580. }
    581. super.onActivityResult(requestCode, resultCode, data);
    582. }
    583. 使用近距离数据通信服务下载数据:
    584. /**
    585. AccountRepository类
    586. * 初始化Network Kit
    587. */
    588. @Override
    589. protected void initManager() {
    590. //下载manager。
    591. downloadManager = new DownloadManager.Builder("downloadManager")
    592. .build(context);
    593. callback = new FileRequestCallback() {
    594. @Override
    595. public GetRequest onStart(GetRequest request) {
    596. return request;
    597. }
    598. @Override
    599. public void onProgress(GetRequest request, Progress progress) {
    600. Log.i(TAG, "onProgress:" + progress);
    601. }
    602. @Override
    603. public void onSuccess(Response response) {
    604. String filePath = "";
    605. if (response.getContent() != null) {
    606. filePath = response.getContent().getAbsolutePath();
    607. }
    608. Log.i(TAG, "onSuccess" + " for " + filePath);
    609. }
    610. @Override
    611. public void onException(GetRequest getRequest, NetworkException e, Response response) {
    612. if (e instanceof Exception) {
    613. String errorMsg = "download exception for paused or canceled";
    614. Log.w(TAG, errorMsg);
    615. } else {
    616. String errorMsg = "download exception for request:" + getRequest.getId() +
    617. "\n\ndetail : " + e.getMessage();
    618. if (e.getCause() != null) {
    619. errorMsg += " , cause : " +
    620. e.getCause().getMessage();
    621. }
    622. Log.e(TAG, errorMsg);
    623. }
    624. }
    625. };
    626. }
    627. @Override
    628. public void download() {
    629. imageDownload(context);
    630. }
    631. private void imageDownload(Context context) {
    632. if (downloadManager == null) {
    633. Log.e(TAG, "can not download without init");
    634. return;
    635. }
    636. String downloadFilePath = context.getObbDir().getPath() + File.separator + "acc111.jpg";
    637. getRequest = DownloadManager.newGetRequestBuilder()
    638. .filePath(downloadFilePath)
    639. .url(Common.getUrlRequest())
    640. .build();
    641. Result result = downloadManager.start(getRequest, callback);
    642. checkResult(result);
    643. }

    九、打包和测试

    1、启动Android Studio,点击运行按钮,在手机或模拟器上运行您的应用。点击登录按钮登录您的应用。

    cke_498185.pngcke_533595.png

    2、登录成功后,展示群界面。点击“+“图标新建群。

    cke_589817.pngcke_624120.png

    3、群在创建完成后将被插入到云数据库中,用户进入主界面,显示最新群列表。

    4、在列表中点击一个群,打开群详情界面。

    cke_775841.png

    5、点击任意一条列表中的账单或收支数据,进入详情界面。

    cke_830907.pngcke_866251.png

    6、点击分享按钮,将账单详情以文件形式发送给附近的好友。该操作使用近距离数据通信服务,无需使用到手机流量和wifi。

    cke_916339.png

    7、查看收到的账单文件。

    cke_969577.png

    8、点击接收按钮接收文件。

    cke_1025092.png

    十、恭喜您

    祝贺您,您已成功构建一款SplitBill应用并学会了:

    • AppGallery Connect中配置云数据库和云存储。

    • 在Android Studio中集成多个HMS Core服务并构建一款SplitBill应用。

    十一、参考

    参考如下文档获取更多信息:

    点击此处下载源码。

    声明:本codelab实现多个HMS Core服务在单个项目中的集成,供您参考。您需要验证确保相关开源代码的安全合法合规。

    欲了解更多更全技术文章,欢迎访问https://developer.huawei.com/consumer/cn/forum/?ha_source=zzh

  • 相关阅读:
    学习亚马逊云科技AWS云计算技术的三款官方免费3A游戏大作
    DataTableResponseEntity
    什么是漂亮排序算法:一顿操作很装逼,一看性能二点七
    Linux开发讲课14--- CPU100%该如何处理
    STC - 官方库函数 - 串口操作修改
    windows环境下使用mmdetection+mmdeploy训练自定义数据集并转成onnx格式部署
    C++ 模板 - CRTP 技法
    由于找不到mfc100u.dll,无法继续执行代码的详细处理方法分享
    【C语言】求解数独 求数独的解的个数 多解数独算法
    java源码系列:链表是什么?数组和它有何不同?(2022-07-28更新完毕)
  • 原文地址:https://blog.csdn.net/weixin_44708240/article/details/127993902