Android蓝牙健康设备开发:Health Device Profile(HDP)

最近按导师的要求做了个小项目:用Android手机跟蓝牙血压计通信。在网上查了很多资料,发现有许多文章是讲解Android蓝牙开发,但是在中文社区缺少针对实现HDP profile的蓝牙健康设备的文章,所以整理了相关知识和代码做一个总结。做了一点微小的贡献,谢谢大家。
1、相关概念
HDP:Health Device Profile顾名思义是针对蓝牙健康设备(如蓝牙血压计、蓝牙体重秤)的一个profile,由蓝牙技术联盟(Bluetooth SIG)发布。并不是所有蓝牙设备都可以采用HDP,只有经典蓝牙(BR/EDR)设备才可以。低功耗蓝牙设备,即蓝牙4.0及以上,采用的是GATT等profile。

上图是HDP的协议栈。在通信时,蓝牙健康设备作为source,Android手机作为sink。图中L2CAP、SDP、MCAP是蓝牙通信的底层协议,中间是IEEE11073协议。11073是IEEE发布的一个健康设备的协议簇,其下包含了许多不同种类的健康设备协议,如11073-10407是血压计的协议。在数据传输时,手机与健康设备根据此协议来发送请求、解析数据等。最上层的application在本文中自然指的就是Android app。
Android蓝牙模块:android.bluetooth是蓝牙的开发包,里面包含了所有蓝牙相关的类,无论是经典蓝牙还是低功耗蓝牙。其中,本文重点关注的是BluetoothHealth这个类,还用到了一些蓝牙基础类BluetoothAdapter BluetoothDevice等,在这里就不展开介绍了。BluetoothHealth是在API14时引入的,所以系统版本高于14的Android手机都可以与采用HDP的健康设备通信。BluetoothHealth中包括了与HDP设备建立通信的API,具体请参见官方文档。在官方文档中有一个建立通信的流程:
1、调用[getProfileProxy(Context, BluetoothProfile.ServiceListener, int)](https://developer.android.com/reference/android/bluetooth/BluetoothAdapter.html#getProfileProxy(android.content.Context, android.bluetooth.BluetoothProfile.ServiceListener, int))来获取代理对象的连接。
2、创建BluetoothHealthCallback回调,调用[registerSinkAppConfiguration(String, int, BluetoothHealthCallback)](https://developer.android.com/reference/android/bluetooth/BluetoothHealth.html#registerSinkAppConfiguration(java.lang.String, int, android.bluetooth.BluetoothHealthCallback))注册一个sink端的应用程序配置。
3、将手机与健康设备配对,这一步一般在手机的“设置”中完成。
4、使用[connectChannelToSource(BluetoothDevice, BluetoothHealthAppConfiguration)](https://developer.android.com/reference/android/bluetooth/BluetoothHealth.html#connectChannelToSource(android.bluetooth.BluetoothDevice, android.bluetooth.BluetoothHealthAppConfiguration))来建立一个与健康设备的通信channel。有的设备会自动建立通信,不需要在代码中调用这个方法。第二步中的回调会指示channel的状态变化。
5、用ParcelFileDescriptor来读取健康设备传来的数据,并根据IEEE 11073来解析数据。
6、通信结束后,关闭通信channel,注销应用程序配置。
看完这个流程是不是一脸懵逼?不要慌,这里官方文档实在太抽象了,必须要配合实际的代码才能看懂。
2、demo代码
项目的目标是与经典蓝牙血压计进行通信,获取血压计测量的数值和日期。这里血压计的型号是A&D UA-767PBT-C。项目中的代码是以github上这个项目为基础,根据需求进行修改而实现的。

项目结构

上图是项目的结构,非常简单,只有一个activity和一个service。activity与用户交互,service绑定在activity中与蓝牙血压计建立通信、交换数据。
首先AndroidManifest.xml中注册蓝牙权限:

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>

MainActivity

主要作用是显示血压计传过来的数据,以及绑定服务。
activity的布局如下:

demo.png

上半部分是一个ArrayList,用于显示手机所配对的蓝牙设备的名称和Mac地址。下半部分会显示一次血压测量的参数:收缩压、舒张压、心率、测量时间。

在MainActivity中一些重要私有变量:

private static final int REQUEST_ENABLE_BT = 1;  //用于打开手机蓝牙
private static final int HEALTH_PROFILE_SOURCE_DATA_TYPE = 0x1007;  //IEEE 11073中规定的血压数据类型
private BluetoothAdapter mBluetoothAdapter;
private Messenger mHealthService;  //用于与service通信
private boolean mHealthServiceBound; //用于判断service是否与此activity绑定

打开蓝牙:


       if (!mBluetoothAdapter.isEnabled()) {
            Intent enableIntent = new Intent(
                    BluetoothAdapter.ACTION_REQUEST_ENABLE);
            startActivityForResult(enableIntent, REQUEST_ENABLE_BT);
        } else {
            initialize();
        }
 

启动服务:

            // Sets up communication with HDPService.
            private ServiceConnection mConnection = new ServiceConnection() {
                public void onServiceConnected(ComponentName name, IBinder service) {
                    mHealthServiceBound = true;
                    Message msg = Message.obtain(null,
                            HDPService.MSG_REG_CLIENT);
                    msg.replyTo = mMessenger;
                    mHealthService = new Messenger(service);
                    try {
                        mHealthService.send(msg);
                        //register blood pressure data type
                        sendMessage(HDPService.MSG_REG_HEALTH_APP,
                                HEALTH_PROFILE_SOURCE_DATA_TYPE);
                    } catch (RemoteException e) {
                        Log.w(TAG, "Unable to register client to service.");
                        e.printStackTrace();
                    }
                }
    
                public void onServiceDisconnected(ComponentName name) {
                    mHealthService = null;
                    mHealthServiceBound = false;
                }
            };
    private void initialize() {
        // Starts health service.
        Intent intent = new Intent(this, HDPService.class);
        //startService(intent);
        bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
    }

用handler和message来实现activity与service的通信:

    private Handler mIncomingHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
              ......
            }
        }
    };

    private final Messenger mMessenger = new Messenger(mIncomingHandler);

HDPService

这个service是核心部分,手机与蓝牙血压计的通信就是在此实现的。
service中部分重要私有变量:

    private BluetoothHealthAppConfiguration mHealthAppConfig; //BluetoothHealthAppConfiguration
    private BluetoothAdapter mBluetoothAdapter;  //BluetoothAdapter
    private BluetoothHealth mBluetoothHealth;  //代理对象

在service中按照官方文档的流程一步一步实现与蓝牙血压计的通信。
1、获取代理对象:

    public void onCreate() {
        super.onCreate();
        mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        if (mBluetoothAdapter == null || !mBluetoothAdapter.isEnabled()) {
            // Bluetooth adapter isn't available.  The client of the service is supposed to
            // verify that it is available and activate before invoking this service.
            stopSelf();
            return;
        }
        if (!mBluetoothAdapter.getProfileProxy(this, mBluetoothServiceListener,
                BluetoothProfile.HEALTH)) {
            Toast.makeText(this, "HDP not available",
                    Toast.LENGTH_LONG).show();
            stopSelf();
            return;
        }
    }

代码中的mBluetoothServiceListener是一个回调,如果getProfileProxy方法返回为真,就进入回调。在回调中获取了代理对象属于BluetoothHealth类的proxy。

private final BluetoothProfile.ServiceListener mBluetoothServiceListener =
            new BluetoothProfile.ServiceListener() {
                public void onServiceConnected(int profile, BluetoothProfile proxy) {
                    if (profile == BluetoothProfile.HEALTH) {
                        mBluetoothHealth = (BluetoothHealth) proxy;
                        Log.d(TAG, "onServiceConnected to profile: " + profile);
                    }
                }

                public void onServiceDisconnected(int profile) {
                    if (profile == BluetoothProfile.HEALTH) {
                        mBluetoothHealth = null;
                    }
                    Log.d(TAG, "onServiceDisconnected");
                }
            };

2、注册BluetoothHealthAppConfiguration:

mBluetoothHealth.registerSinkAppConfiguration(TAG, dataType, mHealthCallback);
private final BluetoothHealthCallback mHealthCallback = new BluetoothHealthCallback() {
        // Callback to handle application registration and unregistration events.  The service
        // passes the status back to the UI client.
        public void onHealthAppConfigurationStatusChange(BluetoothHealthAppConfiguration config,
                                                         int status) {
            if (status == BluetoothHealth.APP_CONFIG_REGISTRATION_FAILURE) {
                mHealthAppConfig = null;
                sendMessage(STATUS_HEALTH_APP_REG, RESULT_FAIL, null);
                Log.d(TAG, "APP_CONFIG_REGISTRATION_FAILURE");
            } else if (status == BluetoothHealth.APP_CONFIG_REGISTRATION_SUCCESS) {
                mHealthAppConfig = config;
                sendMessage(STATUS_HEALTH_APP_REG, RESULT_OK, null);
                Log.d(TAG, "APP_CONFIG_REGISTRATION_SUCCESS");
            } else if (status == BluetoothHealth.APP_CONFIG_UNREGISTRATION_FAILURE ||
                    status == BluetoothHealth.APP_CONFIG_UNREGISTRATION_SUCCESS) {
                sendMessage(STATUS_HEALTH_APP_UNREG,
                        status == BluetoothHealth.APP_CONFIG_UNREGISTRATION_SUCCESS ?
                                RESULT_OK : RESULT_FAIL, null);
                Log.d(TAG, "UNREGISTRATION");
            }
        }

        // Callback to handle channel connection state changes.
        // Note that the logic of the state machine may need to be modified based on the HDP device.
        // When the HDP device is connected, the received file descriptor is passed to the
        // ReadThread to read the content.
        public void onHealthChannelStateChange(BluetoothHealthAppConfiguration config,
                                               BluetoothDevice device, int prevState, int newState, ParcelFileDescriptor fd,
                                               int channelId) {
//            if (Log.isLoggable(TAG, Log.DEBUG))
            Log.d(TAG, String.format("prevState\t%d ----------> newState\t%d",
                    prevState, newState));
            if (prevState == BluetoothHealth.STATE_CHANNEL_CONNECTING &&
                    newState == BluetoothHealth.STATE_CHANNEL_CONNECTED) {
                if (config.equals(mHealthAppConfig)) {
                    mChannelId = channelId;
                    sendMessage(STATUS_CREATE_CHANNEL, RESULT_OK, null);
                    (new ReadThread(fd)).start();
                } else {
                    sendMessage(STATUS_CREATE_CHANNEL, RESULT_FAIL, null);
                }
            } else if (prevState == BluetoothHealth.STATE_CHANNEL_CONNECTING &&
                    newState == BluetoothHealth.STATE_CHANNEL_DISCONNECTED) {
                sendMessage(STATUS_CREATE_CHANNEL, RESULT_FAIL, null);
            } else if (newState == BluetoothHealth.STATE_CHANNEL_DISCONNECTED) {
                Log.d(TAG, "I'm in State Channel Disconnected.");
                if (config.equals(mHealthAppConfig)) {
                    sendMessage(STATUS_DESTROY_CHANNEL, RESULT_OK, null);

                } else {
                    sendMessage(STATUS_DESTROY_CHANNEL, RESULT_FAIL, null);
                }
            } else if (prevState == BluetoothHealth.STATE_CHANNEL_DISCONNECTED &&
                    newState == BluetoothHealth.STATE_CHANNEL_CONNECTED) {
                if (config.equals(mHealthAppConfig)) {
                    mChannelId = channelId;
                    sendMessage(STATUS_CREATE_CHANNEL, RESULT_OK, null);
                    (new ReadThread(fd)).start();
                } else {
                    sendMessage(STATUS_CREATE_CHANNEL, RESULT_FAIL, null);
                }
            }
        }
    };

mHealthCallback是其中的关键,有两个方法onHealthAppConfigurationStatusChange和onHealthChannelStateChange。
onHealthAppConfigurationStatusChange回调会监听AppConfiguration的状态,会在registerSinkAppConfiguration函数返回后调用。
onHealthChannelStateChange回调会监听与source端(在这里也就是血压计)通信通道的状态变化,如果收到发来的数据,会接收到一个ParcelFileDescriptor用于读取。

3、将手机与健康设备配对
这一步在手机的系统设置中进行,不必严格按照这个流程的顺序,可以事先配对好。

4、建立通信通道
这一步不是所有设备都需要,部分设备会自动建立通道与sink端(手机)进行通信。我们所用的这个蓝牙血压计就是如此,会自动建立通道。
如果不能自动建立通信,可以主动调用如下代码:

mBluetoothHealth.connectChannelToSource(mDevice, mHealthAppConfig);

mDevice是将要建立通信的source端,可以用mBluetoothAdapter获取与手机配对的任一设备。

5、读取、解析数据
在与蓝牙设备通信时,我们需要知道一点:通信是双向的,设备会向手机发信息,手机可以也需要向设备发信息。在service中建了两个线程,一个用于读数据,一个用于写数据,通过这两个线程实现与设备的数据交换。

首先看读线程

    private class ReadThread extends Thread {
        private ParcelFileDescriptor mFd;

        public ReadThread(ParcelFileDescriptor fd) {
            super();
            mFd = fd;
            Log.i(TAG, "read thread");
        }


        @Override
        public void run() {
          ...
        }
    }

读线程的调用是在回调方法onHealthChannelStateChange中的,当判断通信通道打开时,会开启一个读线程,并且将回调中提供的ParcelFileDescriptor传入读线程的构造方法,用于读取数据。
具体的读取和解析过程我们来看run方法:

FileInputStream fis = new FileInputStream(mFd.getFileDescriptor());
            byte data[] = new byte[300]; //假定有300byte的数据
            try {
                while (fis.read(data) > -1) {
                    if (data[0] != (byte) 0x00) {
                        String test = byte2hex(data);
                        Log.i(TAG, "test: " + test);
                        if (data[0] == (byte) 0xE2) {
                            Log.i(TAG, "E2");
                            //data_AR
                            count = 1;
                            (new WriteThread(mFd)).start();
                            try {
                                sleep(100);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            count = 2;
                            (new WriteThread(mFd)).start();
                        } else if (data[0] == (byte) 0xE7) {
                            Log.i(TAG, "E7");
                            if (data[18] == (byte) 0x0d && data[19] == (byte) 0x1d)  //fixed report
                            {
                                count = 3;
                                //set invoke id so get correct response
                                invoke = new byte[]{data[6], data[7]};
                                //write back response
                                (new WriteThread(mFd)).start();
                                //parse data!!

                                int length = data[21];
                                Log.i(TAG, "length is " + length);
                                int number_of_data_packets = data[22 + 5]; //03
                                //packet_start starts from handle 0 byte
                                int packet_start = 30; 
                                final int SYS_DIA_MAP_DATA = 1;
                                final int PULSE_DATA = 2;
                                final int ERROR_CODE_DATA = 3;
                                for (int i = 0; i < number_of_data_packets; i++) {
                                    int obj_handle = data[packet_start + 1]; 
                                    switch (obj_handle) {
                                        case SYS_DIA_MAP_DATA:
                                            int sys = byteToUnsignedInt(data[packet_start + 9]);
                                            int dia = byteToUnsignedInt(data[packet_start + 11]);
                                            int map = byteToUnsignedInt(data[packet_start + 13]);
                                            //create team string... 9+13~9+20
                                            Log.i(TAG, "sys is " + sys);
                                            sendMessage(RECEIVED_SYS, sys, null);
                                            Log.i(TAG, "dia is " + dia);
                                            sendMessage(RECEIVED_DIA, dia, null);
                                            Log.i(TAG, "map is " + map);
                                            break;
                                        case PULSE_DATA:
                                            //parse
                                            int pulse = byteToUnsignedInt(data[packet_start + 5]);
                                            Log.i(TAG, "pulse is " + pulse);
                                            sendMessage(RECEIVED_PUL, pulse, null);
                                            String month = byteToString(data[packet_start + 8]);
                                            String day = byteToString(data[packet_start + 9]);
                                            String year = byteToString(data[packet_start + 6]) + byteToString(data[packet_start + 7]);
                                            String hour = byteToString(data[packet_start + 10]);
                                            String minute = byteToString(data[packet_start + 11]);
                                            String date = year + "." + month + "." + day + " " + hour + ":" + minute;
                                            Log.v(TAG, "the date is " + date);
                                            sendMessage(RECEIVED_DATE, 0, date);
                                            break;
                                        case ERROR_CODE_DATA:
                                            //need more signal
                                            break;
                                    }
                                    packet_start += 4 + data[packet_start + 3];    //4 = ignore beginning four bytes
                                }
                            } else {
                                count = 2;
                            }
                        } else if (data[0] == (byte) 0xE4) {
                            count = 4;
                            (new WriteThread(mFd)).start();
                        }
                        //zero out the data
                        for (int i = 0; i < data.length; i++) {
                            data[i] = (byte) 0x00;
                        }
                    }
                    sendMessage(STATUS_READ_DATA, 0, null);
                }
            } catch (IOException ioe) {
            }
            if (mFd != null) {
                try {
                    mFd.close();
                } catch (IOException e) { /* Do nothing. */ }
            }
            sendMessage(STATUS_READ_DATA_DONE, 0, null);
        }

while循环中是对读取数据的解析,这里的解析必须参考IEEE 11073中的内容。这个demo用到了血压计,因此就要使用IEEE 11073-10407(专门针对血压计的协议)。使用其他类型的设备也就要使用相应的IEEE 11073-xxxxx协议。
回到demo中的数据读取和解析,这是一个根据IEEE 11073-10407进行手机与蓝牙设备互相发送数据的过程,下图是运行的log:

log.png

大致解释一下流程:
按下测量按钮后,蓝牙血压计发来一个e2开头的数据,这是一个请求连接的信号。
收到请求连接后,手机用写线程发送给血压计一个回复同意连接,接着再发送一个数据请求血压计的特征。
血压计发来一个e7开头的数据,回复血压计的特征。
测量完毕后,血压计发来一个e7开头的数据,是这次测量的报告。
手机回复确认收到测量数据。
血压计发来一个e4开头的数据,请求断开连接。
手机回复确认断开连接。

想要查看详细数据请看IEEE 11073-10407!请看IEEE 11073-10407!请看IEEE 11073-10407!

接下去是写线程:

private class WriteThread extends Thread {
        private ParcelFileDescriptor mFd;

        public WriteThread(ParcelFileDescriptor fd) {
            super();
            mFd = fd;
        }

        @Override
        public void run() {
            FileOutputStream fos = new FileOutputStream(mFd.getFileDescriptor());
//          Association Response [0xE300]
            final byte data_AR[] = new byte[]{(byte) 0xE3, (byte) 0x00,
                    (byte) 0x00, (byte) 0x2C,
                    (byte) 0x00, (byte) 0x00,
                    (byte) 0x50, (byte) 0x79,
                    (byte) 0x00, (byte) 0x26,
                    (byte) 0x80, (byte) 0x00, (byte) 0x00, (byte) 0x00,
                    (byte) 0x80, (byte) 0x00,
                    (byte) 0x80, (byte) 0x00, (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
                    (byte) 0x80, (byte) 0x00, (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x08,  //bt add for phone, can be automate in the future
                    (byte) 0x3C, (byte) 0x5A, (byte) 0x37, (byte) 0xFF,
                    (byte) 0xFE, (byte) 0x95, (byte) 0xEE, (byte) 0xE3,
                    (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00};
//          Presentation APDU [0xE700]
            final byte data_DR[] = new byte[]{(byte) 0xE7, (byte) 0x00,
                    (byte) 0x00, (byte) 0x12,
                    (byte) 0x00, (byte) 0x10,
                    (byte) invoke[0], (byte) invoke[1],
                    (byte) 0x02, (byte) 0x01,
                    (byte) 0x00, (byte) 0x0A,
                    (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
                    (byte) 0x0D, (byte) 0x1D,
                    (byte) 0x00, (byte) 0x00};

            final byte get_MDS[] = new byte[]{(byte) 0xE7, (byte) 0x00,
                    (byte) 0x00, (byte) 0x0E,
                    (byte) 0x00, (byte) 0x0C,
                    (byte) 0x00, (byte) 0x24,
                    (byte) 0x01, (byte) 0x03,
                    (byte) 0x00, (byte) 0x06,
                    (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x00,
                    (byte) 0x00, (byte) 0x00};

            final byte data_RR[] = new byte[]{(byte) 0xE5, (byte) 0x00,
                    (byte) 0x00, (byte) 0x02,
                    (byte) 0x00, (byte) 0x00};

            try {
                Log.i(TAG, String.valueOf(count));
                if (count == 1) {
                    fos.write(data_AR);
                    Log.i(TAG, "Association Responsed!");
                } else if (count == 2) {
                    fos.write(get_MDS);
                    Log.i(TAG, "Get MDS object attributes!");
//                  fos.write(data_ABORT);
                } else if (count == 3) {
                    fos.write(data_DR);
                    Log.i(TAG, "Data Responsed!");
                } else if (count == 4) {
                    fos.write(data_RR);
                    Log.i(TAG, "Data Released!");
                }
            } catch (IOException ioe) {
            }
        }
    }

写线程的逻辑非常清晰,就是通过同一个ParcelFileDescriptor向血压计发送数据。发送的数据在这里已经写死,一共四组数据:作用依次是确认连接请求;确认收到测量数据;请求血压计的特征;确认断开连接。

大功告成,收到数据后界面如下:

Screenshot_2016-09-18-09-53-32.png

第一次写文章,有各种不足之处,请大家指正。

完整代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容

  • 蓝牙 注:本文翻译自https://developer.android.com/guide/topics/conn...
    RxCode阅读 8,463评论 11 99
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,569评论 25 707
  • 前言 最近在做Android蓝牙这部分内容,所以查阅了很多相关资料,在此总结一下。 基本概念 Bluetooth是...
    猫疏阅读 14,222评论 7 113
  • Guide to BluetoothSecurity原文 本出版物可免费从以下网址获得:https://doi.o...
    公子小水阅读 7,616评论 0 6
  • 那年秋风肆意挥洒 同窗下 我们信誓旦旦 说要戎马天下 许你笑靥如花 阳光灿烂依旧 然而月下阑珊的身影 随往事一遍遍...
    愿时光善待他阅读 331评论 1 1