各位大佬好,我又来记笔记了~~
公司又提新需求了,需要开发一个能通话(呼叫客户的手机号码)自动录音的模块。刚接触这个是蒙的,经过一番研究,可实现通话录音的方式大致有下面几种:
方案一:点击拨号时,调用系统的拨号功能,同时应用内注册通话广播,检测通话状态,接通、挂断来决定开始录音和停止录音,录音可以使用MediaRecorder和AudioRecorder。
优缺点:实现方式简单,开发容易。但是缺点也有,受Android系统版本影响大,每次打开应用都需要进设置页面开启“无障碍”权限才能录音(目前Android8.0的不用),录音对方的声音较小。不过适当优化下 也能用。
方案二:刷机,获取设备root权限,把应用修改为“系统”级别应用,就可以正常录制通话(跟手机自带的通话录音一样),具体怎么刷机自行百度
优缺点:参考手机自带的通话录音功能,效果还是非常好的,但是只能用于一些定制的设备。如正常的一些手机、pad用户就不得行了,因为客户不可能会去刷机来兼容我们的应用。
方案三: SIP软电话,集成第三方的VoIP网络电话,实现网络通话并录音,效果也还行。如linphone框架,也是本文要讲的。
优缺点:使用SIP软电话,前提是要有SIP服务器(网上有很多免费的SIP服务器),后面说具体的实现逻辑,通话录音还可以,双方声音都比较大。
方案四:呼叫时,点击开启系统的录音进行录制,返回我们应用时,把系统的录音文件拿出来展示或上传服务器,哈哈 最笨的方案了,适配主流的机型(前提是手机支持通话录音,获取录音文件的路径各机型适配一下)。
优缺点:兼容性差,不推荐了。
本文主要记录的是 《方案一》 和《方案三》,下面 只介绍关键步骤,详见文末demo
方案一:
大致步骤: 1、权限申请
2、注册广播,开启服务进行录音
3、开始拨号
4、查看通话记录,播放录音文件
需要的权限,项目全部权限在这了,有的可能用不到。
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.MODIFY_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission
android:name="android.permission.BIND_ACCESSIBILITY_SERVICE"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
注册广播:
AndroidManifest文件添加 PhoneStateListener和MediaRecorderService
<receiver
android:name=".callrecord.PhoneStateListener"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PHONE_STATE" />
</intent-filter>
</receiver>
<service
android:name=".callrecord.MediaRecorderService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
PhoneStateListener类:
/**
* @ClassName PhoneStateListener
* @Description TODO
* @Author HK.W 通话录音广播
* @Date 2022/10/15 22:13
*/
public class PhoneStateListener extends BroadcastReceiver {
private static final String TAG = "通话状态监听";
static boolean incoming_flag;
private Context mContext;
@Override
public void onReceive(Context ctx, Intent intent) {
mContext = ctx;
String event = intent.getStringExtra(TelephonyManager.EXTRA_STATE);
Log.d(TAG, "通话状态:state:" + event);
if (event.equals(TelephonyManager.EXTRA_STATE_RINGING)) {
Log.d(TAG, "-->RINGING--正在响铃");
incoming_flag = true;
} else if (event.equals(TelephonyManager.EXTRA_STATE_OFFHOOK)) {
Log.d(TAG, "-->EXTRA_STATE_OFFHOOK--正在通话");
startService(ctx, intent);
} else if (event.equals(TelephonyManager.EXTRA_STATE_IDLE)) {
Log.d(TAG, "-->EXTRA_STATE_IDLE--电话挂断--空闲");
ctx.stopService(new Intent(ctx, MediaRecorderService.class));
//AudioRecordUtil.getInstance().stopRecording();
AudioRecorder.getInstance().stopRecord();
}
}
private void startService(Context context, Intent intent) {
Log.d(TAG, "-->startService--打开服务-检查权限");
String[] PERMISSIONS = {Manifest.permission.RECORD_AUDIO,
Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE};
if (hasPermissions(context, PERMISSIONS)) {
Log.d(TAG, "-->startService--打开服务-权限已打开");
intent.setClass(context, MediaRecorderService.class);
intent.putExtra("incoming_flag", incoming_flag);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
context.startForegroundService(intent);
} else {
context.startService(intent);
}
} else {
Log.d(TAG, "-->startService--打开服务-权限未打开");
}
}
public static boolean hasPermissions(Context context, String... permissions) {
if (context != null && permissions != null) {
for (String permission : permissions) {
if (ActivityCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) {
return false;
}
}
}
return true;
}
}
MediaRecorderService类:
public class MediaRecorderService extends AccessibilityService {
private static final String TAG = "通话状态监听";
NotificationManagerCompat notificationManager;
private boolean incoming_flag;
private String number;
@Override
public void onInterrupt() {
}
@Override
public void onAccessibilityEvent(AccessibilityEvent accessibilityEvent) {
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
Log.d(TAG, "-->startService--进入录音服务");
number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER);
incoming_flag = intent.getBooleanExtra("incoming_flag", false);
String phone = SpUtils.getInstance().getString(this, "phone", "Unknown");
AudioRecorder.getInstance().createDefaultAudio(phone);
AudioRecorder.getInstance().startRecord(new RecordStreamListener() {
@Override
public void recordOfByte(byte[] data, int begin, int end) {
Log.d(TAG, "data:" + data);
}
});
notificationBuilder();
}
return START_STICKY;
}
private void notificationBuilder() {
Log.d(TAG, "-->startService--录音服务--打开通知栏,让服务进入前台,避免被杀掉");
if (Build.VERSION.SDK_INT >= 26) {
String CHANNEL_ID = "my_channel_01";
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Channel title",
NotificationManager.IMPORTANCE_DEFAULT);
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel);
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("")
.setContentText("").build();
startForeground(1, notification);
} else {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "CHANNEL_ID")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Recording")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true);
notificationManager = NotificationManagerCompat.from(this);
notificationManager.notify(1, builder.build());
}
}
@Override
public void onDestroy() {
super.onDestroy();
Log.d(TAG, "-->startService--录音服务--服务被销毁---onDestroy()");
stopRecording();
}
private void stopRecording() {
Log.d(TAG, "-->startService--录音服务--停止录音");
if (Build.VERSION.SDK_INT >= 26) {
stopForeground(true);
} else {
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(1);
}
}
}
功能相关页面截图:



拨号:
private void callPhone(String phoneNumber) {
Intent intentPhone = new Intent(Intent.ACTION_CALL, Uri.parse("tel:" +
phoneEt.getText().toString()));
startActivity(intentPhone);
}
开始录音
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
Log.d(TAG, "-->startService--进入录音服务");
number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER);
incoming_flag = intent.getBooleanExtra("incoming_flag", false);
String phone = SpUtils.getInstance().getString(this, "phone", "Unknown");
//开始录音
AudioRecorder.getInstance().createDefaultAudio(phone);
AudioRecorder.getInstance().startRecord(new RecordStreamListener() {
@Override
public void recordOfByte(byte[] data, int begin, int end) {
Log.d(TAG, "data:" + data);
}
});
notificationBuilder();
}
return START_STICKY;
}
停止录音:
public class PhoneStateListener extends BroadcastReceiver {
private static final String TAG = "通话状态监听";
static boolean incoming_flag;
private Context mContext;
@Override
public void onReceive(Context ctx, Intent intent) {
mContext = ctx;
String event = intent.getStringExtra(TelephonyManager.EXTRA_STATE);
Log.d(TAG, "通话状态:state:" + event);
if (event.equals(TelephonyManager.EXTRA_STATE_RINGING)) {
Log.d(TAG, "-->RINGING--正在响铃");
incoming_flag = true;
} else if (event.equals(TelephonyManager.EXTRA_STATE_OFFHOOK)) {
Log.d(TAG, "-->EXTRA_STATE_OFFHOOK--正在通话");
startService(ctx, intent);
} else if (event.equals(TelephonyManager.EXTRA_STATE_IDLE)) {
Log.d(TAG, "-->EXTRA_STATE_IDLE--电话挂断--空闲");
ctx.stopService(new Intent(ctx, MediaRecorderService.class));
//AudioRecordUtil.getInstance().stopRecording();
//为什么不在服务里面停止录音?有的机型挂断电话后没有马上销毁服务,所以在状态这里直接停止录音
AudioRecorder.getInstance().stopRecord();
}
}
本文demo 录音文件保存在根目录anyi.phone/record 文件下。
获取通话记录对应的录音文件:
/**
* 获取录音文件路径 --通话记录
*/
private List<RecordBean> getLocalRecord() {
List<ContactsBean> contacts = readContacts();
List<RecordBean> list = new ArrayList<>();
JSONArray allFiles = getAllFiles("", "wav");
//Log.d("allFiles", "allFiles:" + allFiles.toString());
if (null != allFiles) {
for (int i = 0; i < allFiles.length(); i++) {
try {
JSONObject jsonObject = allFiles.getJSONObject(i);
String name = jsonObject.getString("name");
String path = jsonObject.getString("path");
String[] split1 = name.split("-");
if (split1.length > 0) {
RecordBean recordBean = new RecordBean();
recordBean.setNumber(split1[0]);
recordBean.setPath(path);
recordBean.setDate(new SimpleDateFormat("HH:mm").format(new Date(Long.parseLong(split1[1]))));
if (contacts.size() > 0) {
for (ContactsBean b : contacts) {
if (split1[0].equals(b.getNumber())) {
recordBean.setCachedName(b.getName());
}
}
} else {
recordBean.setCachedName("未知");
}
list.add(recordBean);
}
} catch (JSONException e) {
e.printStackTrace();
}
}
Collections.reverse(list);
return list;
}
return list;
}
public static JSONArray getAllFiles(String dirPath, String _type) {
dirPath = "/storage/emulated/0/anyi.phone/record/";
File f = new File(dirPath);
if (!f.exists()) {//判断路径是否存在
return null;
}
File[] files = f.listFiles();
if (files == null) {//判断权限
return null;
}
JSONArray fileList = new JSONArray();
for (File _file : files) {//遍历目录
if (_file.isFile() && (_file.getName().endsWith("amr")||_file.getName().endsWith("wav"))) {
String _name = _file.getName();
String filePath = _file.getAbsolutePath();//获取文件路径
String fileName = _file.getName().substring(0, _name.length() - 4);//获取文件名
try {
JSONObject _fInfo = new JSONObject();
_fInfo.put("name", fileName);
_fInfo.put("path", filePath);
fileList.put(_fInfo);
} catch (Exception e) {
}
} else if (_file.isDirectory()) {//查询子目录
//getAllFiles(_file.getAbsolutePath(), _type);
} else {
}
}
return fileList;
}
播放:
private void initPlay() {
mediaPlayer = new MediaPlayer();
}
private void startPlay(String path) {
if (TextUtils.isEmpty(path)) {
Toast.makeText(this, "文件路径不存在", Toast.LENGTH_LONG).show();
return;
}
mediaPlayer.reset(); //清空里面的其他歌曲
try {
mediaPlayer.setDataSource(path);
mediaPlayer.prepare(); //准备就绪
mediaPlayer.start(); //开始唱歌
} catch (IOException e) {
e.printStackTrace();
}
}
方案三,SIP通话录音,linphone 为例,只调试了音频通话,视频通话未调试
前提准备
准备一个SIP服务器地址和一个账号密码。可以自己搭建SIP服务器或者网上找一个SIP服务器注册 一个账号密码。下面是网上找的资源,没试过。因为我们公司是购买的有SIP话机服务器的。
免费sip账号注册地址 http://serweb.iptel.org/user/reg/index.php
免费sip服务器 iptel.org
免费sip客户端 http://www.fring.com
正文:
1、把linphone-sdk-android-4.3.0-beta.aar包放在项目libs,提取码: nq6q。
2、配置文件注册服务:
<service
android:name=".linphone.LinphoneService"
android:enabled="true"
android:exported="true"
android:label="@string/app_name" />
3.在启动页 启动SIP相关服务,
启动页:
public class LauncherActivity extends AppCompatActivity {
private static final String TAG = "XXPermissions";
private Handler mHandler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_launcher);
mHandler = new Handler();
}
@Override
protected void onStart() {
super.onStart();
getPermission();
}
private void getPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
XXPermissions.with(this)
.permission(allPermission)
.request(new OnPermissionCallback() {
@Override
public void onGranted(List<String> permissions, boolean all) {
if (all) {
if (LinphoneService.isReady()) {
onServiceReady();
} else {
startService(new Intent().setClass(LauncherActivity.this, LinphoneService.class));
new ServiceWaitThread().start();
}
}
}
@Override
public void onDenied(List<String> permissions, boolean never) {
if (never) {
Log.e(TAG, "onDenied:被永久拒绝授权,请手动授予权限 ");
} else {
Log.e(TAG, "onDenied: 权限获取失败");
}
}
});
} else {
if (LinphoneService.isReady()) {
onServiceReady();
} else {
startService(new Intent().setClass(LauncherActivity.this, LinphoneService.class));
new ServiceWaitThread().start();
}
}
}
private void onServiceReady() {
Intent intent = new Intent();
intent.setClass(LauncherActivity.this, MainActivity.class);
if (getIntent() != null && getIntent().getExtras() != null) {
intent.putExtras(getIntent().getExtras());
}
intent.setAction(getIntent().getAction());
intent.setType(getIntent().getType());
startActivity(intent);
}
private class ServiceWaitThread extends Thread {
public void run() {
while (!LinphoneService.isReady()) {
try {
sleep(30);
} catch (InterruptedException e) {
throw new RuntimeException("waiting thread sleep() has been interrupted");
}
}
mHandler.post(new Runnable() {
@Override
public void run() {
onServiceReady();
}
});
}
}
}
首页activity onResume()方法中检测 账号是否注册,未注册跳转到注册页面:
@Override
protected void onResume() {
super.onResume();
Log.d(TAG, "onResume()");
LinphoneService.getCore().addListener(mCoreListener);
ProxyConfig proxyConfig = LinphoneService.getCore().getDefaultProxyConfig();
if (proxyConfig != null) {
updateLed(proxyConfig.getState());
} else {
startActivity(new Intent(this, ConfigureAccountActivity.class));
}
}
注册:
/**
* 注册
*/
private void configureAccount() {
mAccountCreator.setUsername(mUsername.getText().toString());
mAccountCreator.setDomain(mDomain.getText().toString());
mAccountCreator.setPassword(mPassword.getText().toString());
switch (mTransport.getCheckedRadioButtonId()) {
case R.id.transport_udp:
mAccountCreator.setTransport(TransportType.Udp);
break;
case R.id.transport_tcp:
mAccountCreator.setTransport(TransportType.Tcp);
break;
case R.id.transport_tls:
mAccountCreator.setTransport(TransportType.Tls);
break;
}
ProxyConfig cfg = mAccountCreator.createProxyConfig();
LinphoneService.getCore().setDefaultProxyConfig(cfg);
}
public void listener(){
mCoreListener = new CoreListenerStub() {
/**
* 监听注册是否成功
* @param core
* @param cfg
* @param state
* @param message
*/
@Override
public void onRegistrationStateChanged(Core core, ProxyConfig cfg, RegistrationState state, String message) {
registerPr.setVisibility(View.GONE);
if (state == RegistrationState.Ok) {
finish();
} else if (state == RegistrationState.Failed) {
Toast.makeText(ConfigureAccountActivity.this, "Failure: " + message, Toast.LENGTH_LONG).show();
}
}
};
}
注册成功开始通话:
private void sipCallIng() {
Core core = LinphoneService.getCore();
Address addressToCall = core.interpretUrl(phoneEt.getText().toString());
CallParams params = core.createCallParams(null);
params.enableVideo(false);
if (addressToCall != null) {
String filePath = AudioRecordUtil.getInstance().getFilename(phoneEt.getText().toString(), ".wav");
android.util.Log.d("linPhone--", "开始呼叫--号码--filePath = " + filePath);
//重要:通话前需要设置录音文件,要不不会录音,
params.setRecordFile(filePath);
core.inviteAddressWithParams(addressToCall, params);
Intent intent = new Intent(getActivity(), CallActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
}
开始录音:
/**
* ---通话接通--开始录音
*/
private void startRecord() {
android.util.Log.d("linPhone--", "接通或者拒绝");
android.util.Log.d("linPhone--", "开始录音:录音地址:" + core.getRecordFile());
call.startRecording();
}
停止录音:
/**
* ---通话挂断--停止录音--销毁页面
*/
private void stopRecord() {
android.util.Log.d("linPhone--", "挂断,未接");
android.util.Log.d("linPhone--", "停止录音");
call.stopRecording();//停止录音
finish();//挂断电话-销毁页面
}
后面就是拿到录音文件播放,-----具体就不说了,
研究SIP也用了大量时间和下载了很多大佬的资源,也花费了很多积分,
so 想要demo的朋友们也希望支持一下,
demo需要积分下载,具体多少由平台分配。
本文demo成功实现了两种主流的通话录音方式,应该是能满足你们的业务需求的,
验证北美电话号码的规则是什么?另外,有没有我可以使用的regex?有gem可以做到这一点吗?这里有几条我想到的规则一个10位数字没有特殊字符正数 最佳答案 有很多gem可以为您做到这一点。看看:http://rubygems.org/search?utf8=%E2%9C%93&query=phone+number这个看起来会满足您的需要——它实际上实现了一个正则表达式来验证电话号码:http://rubygems.org/gems/validates_phone_number对于美国、加拿大(百慕大、巴哈马...等和所有+1号码),
有没有办法控制faker生成的电话号码的格式?当我打电话时:Faker::PhoneNumber.cell_phone.to_i我最终得到了错误的值。我也不想有扩展。 最佳答案 您可以像这样即时设置自定义格式:Faker::Base.numerify('+90(###)#######')这将解决您的问题。 关于ruby-on-rails-更改fakergem电话号码格式,我们在StackOverflow上找到一个类似的问题: https://stackover
如果我有这样的国际电话号码:0541754301我怎样才能格式化它来产生这样的东西:0541-754-301 最佳答案 您可以使用ActionView::Helpers::NumberHelper中的number_to_phone(number,options={})方法但是,文档指出此方法会将数字格式化为美国电话号码(例如(555)123-9876)。相反,您可以使用thispatch它增加了提供数字分组的能力::groupings-Specifiesalternategroupings(mustspecify3-elementa
我正在使用伪造的格式设置电话号码(意思是,如果我输入xxx-xxx-xxxx,它会转换为字符串,并且还会判断之前是否有(1)以将其删除)。但这对我们的电话号码真的不起作用,它是为国际号码设计的。有没有等价物?谢谢。http://rubygems.org/gems/phony 最佳答案 今年早些时候,我查看了一堆解析和格式化电话号码的rubygem。他们分为许多组(见下文)。TLDR:我用的是“电话”。它可能对您有用,因为您可以指定一个默认国家代码,如果您的电话号码不包含国家代码,它会使用该代码。1)以美国为中心:大骗子(0.1.
当我的应用启动时,情节板启动屏幕显示我的图像如预期的,但部分被灰色盒子覆盖。有人可以让我知道图像框的来源吗?启动屏幕上唯一的东西是页面上的图像。这是屏幕截图:看答案您是否检查了启动图像是否损坏了?
问题:帖子的请求参数作为请求主体,而不是请求参数。我正在使用下面的此语法来调用SparkJavaWeb服务。http://localhost:8080/cumbcustomer?custId#4&amp;name=fredj"SparkJava告诉我:请求IP0:0:0:0:0:0:0:0:1请求动词post请求接收到:CUSTID#4&amp;name=fredj(-&gt;request.body.body())url接收:http://localhost:8080/cumbscustomer有什么想法为什么这些变量作为请求主体而不是请求参数的一部分出现?提前致谢,看答案利用request
如问题所述,为什么将电话号码作为字符串而不是整数存储在telephone_number列中被认为是最佳实践?不确定我是否理解这样做的理由。请帮忙解决这个问题!谢谢! 最佳答案 电话号码是数字字符串,不是整数。例如考虑:用不同的基数表示电话号码会使它变得毫无意义将两个电话号码相加或相乘,或者对电话号码进行任何数学运算,都是没有意义的。结果不是另一个电话号码(巧合除外)电话号码应“按原样”输入连接的设备。电话号码可以有前导零。电话号码的操作,例如添加区号,是字符串操作。存储电话号码的字符串版本使其清晰明确。历史:在旧的脉冲编码拨号系统中
是否有用于接收电话号码(任何格式)、将其转换为默认格式并告诉我有关号码的信息(例如:国家/地区、城市等)的node.js库? 最佳答案 我不知道一个,我也搜索了npmregistry.Google有libphonenumber用于处理电话号码并具有javascriptAPI。来自他们的网站:Google'scommonJava,C++andJavascriptlibraryforparsing,formatting,storingandvalidatinginternationalphonenumbers.TheJavaversio
我有文本字段让用户填写他的电话号码,但我需要强制该用户以如下特定格式填写:(0599)-9-111222。我的代码是:functionfomratPhone(phone){for(vari=0;i但是这段代码不起作用。 最佳答案 另一种选择是jQueryinputmaskplugin$(function(){$("#phone").inputmask("mask",{"mask":"(9999)-9-999999"});});这只是NF提供的另一种变体。我没有用过NF提到的那个,但是我用过这个没有问题。此外,因为它是一个github
Underscore提供了方法,throttle。来自他们的文档:创建并返回所传递函数的新的throttle版本,当重复调用时,每等待毫秒最多只会实际调用一次原始函数。对于发生速度快于您无法跟上的速率限制事件很有用。现在想象一下自动完成表单的情况。这意味着,如果在100毫秒窗口内键入“abc”,则只会发送对“a”的搜索,而不是“bc”。这是对underscore.js的严重疏忽吗?作为干净的解决方案,您会建议什么? 最佳答案 对于此用例,您可能希望使用以下“缓冲”函数,它将仅应用等待窗口中的最后一次调用。https://gist.g