65.9K
CodeProject 正在变化。 阅读更多。
Home

CEDB .NET

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.38/5 (6投票s)

2005 年 4 月 24 日

CPOL

13分钟阅读

viewsIcon

89472

downloadIcon

600

使用一些 C++ 辅助功能,实现 CEDB 数据库引擎的托管包装器。

引言

本文介绍了如何通过混合托管和非托管代码,从 .NET Compact Framework 使用 Windows CE 属性数据库。文中提供了一个示例应用程序,该应用程序实现了所讨论的托管和非托管类,并实现了一个非常简单的联系人数据库编辑器。

Windows CE 属性数据库

Windows CE 属性数据库,也称为 CEDB,是一种非常简单的持久化应用程序数据的方式。每个数据库仅包含一个没有预设结构的表。记录可能包含可变数量的字段,并且每个数据库最多允许四种排序顺序。这些数据库可以直接创建在对象存储上,或挂载在文件上。

尽管它们在定义和使用方面似乎都非常有限(在一个包含超过 1000 条记录的表中可能会遇到问题),但这些数据库在 Pocket PC 上非常普及:它们支持所有 PIM 应用程序,支持流行的“Pocket Access”格式,并且可以通过 RAPI 直接从桌面访问。

CEDB 的非托管应用程序编程接口非常简单,令人惊讶的是,Compact Framework 中没有其托管版本。初步了解后,人们会想知道为什么没有 CEDB 包装器的实现。有一些针对这些数据库的高级包装器,但它们依赖于 ADOCE – 一个微软正在停用的 COM 组件。因此,这里有一个有趣的挑战:将低级 CEDB API 包装到一个托管库中。

建模属性数据库

在构建托管包装器之前,需要理解属性数据库包含的几个非常简单的概念。

  • 数据库被分组到卷中。卷可以存储在文件中(在这种情况下,它是挂载卷),也可以是对象存储本身。当数据库存储在对象存储上时,它们不会直接显示为文件。挂载卷是常规文件,并被这样管理。卷由存储在 CEGUID 结构中的值标识。

  • 数据库

    数据库实际上是一个包含数据记录的单个表。属性数据库和 SQL 表之间的主要区别在于缺少架构信息。

  • Record

    记录以属性形式存储相关数据。每条记录可能包含可变数量的属性,并且没有一个属性是必需的。这使得其结构非常松散。

  • 属性

    属性是基本的数据存储单元。它具有唯一的标识符、数据类型和数据本身。唯一标识符和数据类型组合成一个 32 位属性 ID 或 PROPID

  • 排序顺序

    排序顺序决定了数据库中的记录如何排序,其作用类似于非唯一索引。每个数据库最多允许 4 种排序顺序。

现在,我们可以围绕这些概念开始设计类。在示例应用程序中,实现了以下类:

  • CeDbApi

    包含其他类使用的所有导入的 API 函数。

  • CeDbException

    包装器抛出的异常类型。

  • CeDbInfo

    包装 CEDBASEINFO 结构,用于创建新数据库和查询现有数据库。

  • CeDbProperty

    模型属性值。

  • CeDbPropertyCollection

    属性集合,可通过属性 ID 搜索。

  • CeDbPropertyID

    用于管理属性 ID 的静态类。

  • CeDbRecord

    模型数据库记录。

  • CeDbRecordSet

    实现数据库的数据访问和导航。

  • CeDbTable

    标识卷中的数据库。

  • CeDbVolume

    模型数据库卷。

  • CeOidInfo

    检索有关现有数据库的信息(可以推广到其他对象存储项)。

数据库卷

数据库可以创建在对象存储上(没有可见文件)或挂载在文件上,称为命名卷。为了标识数据库存在的位置,API 使用 CEGUID 结构。其状态可以是无效的,标识对象存储或挂载卷。该结构很容易映射到 C# 代码。

public struct CEGUID
{
    public int Data1;
    public int Data2;
    public int Data3;
    public int Data4;

    public static CEGUID InvalidGuid()
    {
        CEGUID    ceguid;

        ceguid.Data1 = -1;
        ceguid.Data2 = -1;
        ceguid.Data3 = -1;
        ceguid.Data4 = -1;

        return ceguid;
    }

    public static CEGUID SystemGuid()
    {
        CEGUID ceguid;

        ceguid.Data1 = 0;
        ceguid.Data2 = 0;
        ceguid.Data3 = 0;
        ceguid.Data4 = 0;

        return ceguid;
    }
}

SystemGuid 静态方法创建一个具有标识对象存储上数据库的值的结构实例。要指定文件中的数据库,您必须首先将其挂载为卷。卷通过 CeMountDBVol API 挂载,该 API 通过引用返回一个 CEGUID 值。

public static extern 
bool CeMountDBVol(ref CEGUID ceguid, string strDbVol, FileFlags flags);

FileFlags 枚举包含标准的打开和创建文件标志。

public enum FileFlags
{
    CreateNew        = 1,
    CreateAlways     = 2,
    OpenExisting     = 3,
    OpenAlways       = 4,
    TruncateExisting = 5
}

使用 OpenExisting 标志打开现有数据库卷,并使用 CreateAlways 标志创建新卷(这将删除同名现有卷文件)。

卷由示例项目中的 CeDbVolume 类管理。该类实现了 IDisposable 接口,因为它处理非托管资源。可以通过 Mount 方法挂载卷,并通过 Unmount 方法卸载卷。通过调用 UseSystem 方法选择对象存储卷。

创建、打开和关闭数据库

一旦有了数据库名称和其所在的卷,打开数据库就非常容易了 – 使用 CeOpenDatabaseEx

public static extern
IntPtr CeOpenDatabaseEx(ref CEGUID ceguid, ref int oid, string strName, 
         uint propid, uint flags, IntPtr pRequest);

第一个参数是标识卷的 CEGUID 结构。第二个参数是对数据库标识符的引用,该标识符通过引用返回。第三个参数是数据库名称,例如“Contacts Database”。第四个参数是排序顺序的属性标识符(稍后详细介绍)。第五个参数是一个标志,指示记录如何读取。

public enum CeDbOpenFlags : uint
{
    None          = 0,
    AutoIncrement = 1
}

AutoIncrement 标志表示每当读取一条记录时,记录指针会立即递增。最后一个参数是指向 CENOTIFYREQUEST 结构的指针,该结构包含通知请求信息。在此示例中,我们将不使用此功能,因此参数的值将为 IntPtr.Zero

该函数以 IntPtr 值返回一个句柄到已打开的数据库。这是我们用于通过 CloseHandle 函数关闭数据库的值。如果函数失败,此值为 IntPtr.Zero

public static extern bool CloseHandle(IntPtr hHandle);

创建数据库稍微复杂一些,因为我们必须通过 CEDBASEINFO 结构提供一些创建信息。该结构以及数据库卷 CEGUID 值被传递给 CeCreateDatabaseEx 函数。

public static extern int CeCreateDatabaseEx(ref CEGUID ceguid, byte[] info);

正如您在导入声明中看到的,没有引用 CEDBASEINFO 结构,而是引用了一个字节数组。事实上,在 Compact Framework 中,这个结构很难进行封送处理,因为它包含一个嵌入的字符串和一个 SORTORDERSPEC 数组。

typedef struct _CEDBASEINFO {
    DWORD dwFlags;
    WCHAR szDbaseName[CEDB_MAXDBASENAMELEN];
    DWORD dwDbaseType;
    WORD  wNumRecords;
    WORD  wNumSortOrder;
    DWORD dwSize;
    FILETIME ftLastModified;
    SORTORDERSPEC rgSortSpecs[CEDB_MAXSORTORDER];
} CEDBASEINFO;

使用 Alex Yakhnin 已经描述过的 技术,我们将结构转换为一个扁平的字节数组,并将其提供给函数,该函数将愉快地将其作为原生代码使用者生成的结构来使用。

但在我们能够将所有这些投入使用之前,我们需要创建一个包装器类,它将隐藏 CEDBASEINFO 封送处理的所有实现细节,同时为托管使用者保留一个合适的接口。此类在示例应用程序中实现了为 CeDbInfo

此类实现为一个 120 个元素的字节数组,这是 CEDBASEINFO 结构的精确大小。所有方法和属性都操作托管类型,并在序列化字节数组格式之间进行转换。例如,让我们看看处理数据库名称的属性——CEDBASEINFOszDbaseName 字符数组。该数组位于字节数组开头偏移量 4 的位置,长度为 64 字节(32 个字符,包括空终止符)。

public string Name
{
    get
    {
        string strName = BitConverter.ToString(m_data, 4, 64);
        char[] cTrim   = {'\0', ' '};

        return strName.Trim(cTrim);
    }

    set 
    {
        string    strName;
        byte[]    name;

        if(value.Length > 31)
            strName = value.Substring(0, 31) + '\0';
        else
            strName = value + '\0';
        name = UnicodeEncoding.Unicode.GetBytes(strName);

        Buffer.BlockCopy(name, 0, m_data, 4, name.Length);
    }
}

这显然不是解决此问题的唯一方法。我们也可以将名称属性存储为类中的托管字符串,仅在需要转换时才将其呈现为字节数组。

在示例应用程序中,属性数据库由 CeDbTable 类表示。它包含对卷和数据库名称的引用,其主要目的是创建一个有助于更新表的类:CeDbRecordSet 类。

更新数据库

现在我们已经成功获取了数据库(实际上是表)的句柄,我们需要访问其中存储的信息。数据库结构化为行记录,每条记录包含可变数量的字段。每个字段都有一个唯一的标识符,并可以包含有限数量的数据类型。

public enum CeDbType : ushort
{
    Int16    =  2,
    UInt16   = 18,
    Int32    =  3,
    UInt32   = 19,
    FileTime = 64,
    String   = 31,
    Blob     = 65,
    Bool     = 11,
    Double   =  5
}

数据类型的数值与唯一 ID(16 位整数)组合以生成 32 位属性标识符。我们使用一个静态类来管理这些,而不是使用 C 宏。

namespace Primeworks.CeDb
{
    public class CeDbPropertyID
    {
        public static uint Create(CeDbType type, ushort id)
        {
            return (uint)type + ((uint)id << 16);
        }

        public static uint Create(byte[] data, int iOffset)
        {
            return BitConverter.ToUInt32(data, iOffset);
        }

        public static CeDbType GetCeDbType(uint propid)
        {
            return (CeDbType)(propid & 0x0000ffff);
        }

        public static ushort GetId(uint propid)
        {
            return (ushort)((propid & 0xffff000) >> 16);
        }
    }
}

现在,让我们看看属性内部,了解如何使用 C# 来模拟它。在原生环境中,属性存储为 16 字节结构。

typedef struct _CEPROPVAL { 
    CEPROPID   propid;    // Property ID
    WORD       wLenData;  // Private
    WORD       wFlags;    // Field flags
    CEVALUNION val;       // Property value
} CEPROPVAL;

propid 值存储属性标识符,val 成员存储值。属性值存储在 C 联合体中。

typedef union _CEVALUNION {
    short    iVal;      // Int16
    USHORT   uiVal;     // UInt16
    long     lVal;      // Int32
    ULONG    ulVal;     // UInt32
    FILETIME filetime;  // DateTime
    LPWSTR   lpwstr;    // Unicode string pointer
    CEBLOB   blob;      // BLOB
    BOOL     boolVal    // Boolean (Int32)
    double   dblVal     // Double
} CEVALUNION;

这些类型中的大多数都可以快速转换为托管类型,但有两个例外:Unicode 字符串指针和 BLOB。它们都包含指向内存块的指针,在读写时必须正确处理。

当读取数据库记录时,从本地堆中返回一个单一内存块。该块包含检索到的记录的所有信息,并且任何字符串或 BLOB 指针也指向它。在桌面 .NET Framework 上读取这种类型的数据会相对容易,因为它具有先进的封送处理代码。Compact Framework 的封送处理资源要有限得多,因此我们通过将指针转换为数组偏移量来借助非托管 C++ 代码。这在读写记录时都必须进行。让我们从读取记录的代码开始。

CEDBNET_API CEOID CeDbNetReadRecord(HANDLE hDbase,
                                    WORD*  pProps, 
                                    BYTE** ppBuffer,
                                    DWORD* pSize)
{
    BYTE* pBuffer = NULL;
    CEOID ceoid;

    ceoid = CeReadRecordPropsEx(hDbase, CEDB_ALLOWREALLOC, 
                                pProps, NULL, &pBuffer, pSize, 
                                NULL);
    if(ceoid)
    {
        DWORD      dwOffset = 0;
        CEPROPVAL* pCur     = (CEPROPVAL*)pBuffer;
        WORD       iProp,
                   nProps   = *pProps;

        for(iProp = 0; iProp < nProps; ++iProp, ++pCur)
        {
            switch(TypeFromPropID(pCur->propid))
            {
            case CEVT_BLOB:
                dwOffset = (DWORD)pCur->val.blob.lpb;
                dwOffset -= (DWORD)pBuffer;

                pCur->val.blob.lpb = (LPBYTE)dwOffset;
                break;

            case CEVT_LPWSTR:
                pCur->val.blob.lpb = (LPBYTE)
                ((wcslen(pCur->val.lpwstr) + 1) * sizeof(WCHAR));

                dwOffset = (DWORD)pCur->val.lpwstr;
                dwOffset -= (DWORD)pBuffer;

                pCur->val.lpwstr = (LPWSTR)dwOffset;
                break;
            }
        }

    }
    *ppBuffer = pBuffer;

    return ceoid;
}

这里我们调用 API 读取下一条数据库记录,并遍历其属性,将所有指针转换为数组偏移量。对于 BLOB 来说,这种情况很简单:它同时包含指针(现在是偏移量)和字节大小。字符串的处理稍微复杂一些,因为 C 字符串不包含显式长度——必须根据空终止符的位置推断出来。这段代码通过计算字符串长度(加上终止符)并将其存储在 BLOB 偏移量之后来处理此问题。这是使用 BLOB 指针成员完成的,因为它位于字符串指针之后。感到困惑吗? BLOB 的存储方式如下:

typedef struct _CEBLOB {
    DWORD           dwCount;
    LPBYTE          lpb;
} CEBLOB;

C 编译器在 CEVALUNION 联合体中打包此结构时,blob.dwCountlpwstr 共享相同的偏移量,因此 blob.lpb 占据接下来的四个字节——在属性结构中的最后一个字节。上面的代码所做的是,以与 BLOB 存储的顺序相反的顺序,将字符串长度存储在字符串指针(现已转换为偏移量)之后。

现在读取这些信息要简单得多,但我们仍然需要将其封送到托管世界。首先,我们需要将此函数映射到一个 C# 方法。

public static extern 
int CeDbNetReadRecord(IntPtr hDbase, ref short nProps, ref IntPtr pBuffer,
                      ref int nSize);

除了返回记录的 OID 外,该方法还通过引用参数返回记录上的属性数量、指向这些属性的指针以及缓冲区大小。请注意,此函数将始终检索完整记录。要仅检索记录的一部分,还需要提供另外两个参数(属性标识符的数量和数组)。

现在,可以将属性缓冲区读入托管字节数组,然后将其拆分为各个属性。一个非常简单的原生函数有助于封送处理过程。

CEDBNET_API void CeDbNetLocalToArray(BYTE *pLocal, BYTE *pArray, int nSize)
{
    memcpy(pArray, pLocal, nSize);
    LocalFree(pLocal);
}

该函数获取前一个函数返回的缓冲区,将其复制到托管字节数组中,然后释放它。其托管签名是:

public static extern
void CeDbNetLocalToArray(IntPtr hLocal, byte[] data, int nSize);

检索到属性缓冲区后,必须将其拆分为存储在集合中的各个属性。处理此任务的类是 CeDbRecord。记录的读取在 Read 方法中进行,让我们看一下:

public void Read(IntPtr hDbase)
{
    int    nSize   = 0;
    short  nProps  = 0;
    IntPtr pBuffer = IntPtr.Zero;

    m_arrProp.Clear();

    // Read the raw record
    m_oid = CeDbApi.CeDbNetReadRecord(hDbase, ref nProps, 
                                      ref pBuffer, ref nSize);
    if(m_oid != 0)
    {
        int    iProp;
        byte[] data = new byte[nSize];

        // Copy the HLOCAL to the array and release it
        CeDbApi.CeDbNetLocalToArray(pBuffer, data, nSize);

        // Add all the properties to the record
        for(iProp = 0; iProp < (int)nProps; ++iProp)
        {
            CeDbProperty prop = new CeDbProperty(data, iProp * 16);

            m_arrProp.Add(prop);
        }
    }
}

通过连续调用前两个函数来读取记录,然后遍历所有记录并构建类型为 CeDbProperty 的新对象,该类代表单个属性。请注意字节索引如何以 16 字节块为单位前进。在这段代码中,我们没有看到如何读取字符串或 BLOB。答案在于构造函数。

public CeDbProperty(byte[] data, int iOffset)
{
    int iData = 0;
    int nSize = 0;

    m_propid = CeDbPropertyID.Create(data, iOffset);

    Buffer.BlockCopy(data, iOffset, m_prop, 0, 16);

    switch(CeDbPropertyID.GetCeDbType(m_propid))
    {
        case CeDbType.Blob:
            nSize = BitConverter.ToInt32(data, iOffset +  8);
            iData = BitConverter.ToInt32(data, iOffset + 12);

            m_data = new byte[nSize];
            Buffer.BlockCopy(data, iData, m_data, 0, nSize);
            break;

        case CeDbType.String:
            nSize = BitConverter.ToInt32(data, iOffset + 12);
            iData = BitConverter.ToInt32(data, iOffset +  8);

            m_data = new byte[nSize];
            Buffer.BlockCopy(data, iData, m_data, 0, nSize);
            break;

        default:
            m_data = null;
            break;
    }
}

m_prop 变量是一个 16 字节的字节数组,由类的属性和方法进行操作。它以原生格式保存,以便于读写,这对于简单数据类型来说足够了。字符串和 BLOB 以其原生格式存储在 m_data 字节数组中。上面显示的用于分配此数组的代码展示了字符串和 BLOB 之间如何处理偏移量和长度字的颠倒。

将记录写入数据库稍微复杂一些,因为必须通过构建一个包含所有属性及其相应字符串和 BLOB 的单个字节缓冲区来反转上述过程。此过程分两个阶段完成:构建托管字节数组,以及通过将所有数组偏移量转换为原生指针来将其转换为正确格式的记录缓冲区。让我们从 CeDbRecord 类的 Write 方法开始。

public int Write(IntPtr hDbase, int oid)
{
    int    iProp;
    int    nSize = 0;
    int    iData = 0;
    byte[] data  = null;

    // Calculate the total size of the blob
    foreach(CeDbProperty prop in m_arrProp)
    {
        nSize = AddOffset(nSize, 16);
        nSize = AddOffset(nSize, prop.DataSize);
    }

    // Allocate the data buffer
    data = new byte[nSize];

    // Calculate the data offset
    iData = m_arrProp.Count * 16;

    // Copy the CEPROPVAL structures
    iProp = 0;
    foreach(CeDbProperty prop in m_arrProp)
    {
        int nDataSize = prop.DataSize;

        Buffer.BlockCopy(prop.GetPropBytes(), 0, data, iProp * 16, 16);

        // Copy the data blob
        if(nDataSize > 0)
        {
            Buffer.BlockCopy(prop.GetDataBytes(), 0, data,
                             iData, nDataSize);

            // Calculate blob offsets
            if(CeDbPropertyID.GetCeDbType(prop.PropID) == CeDbType.String)
            {
                // String
                Buffer.BlockCopy(BitConverter.GetBytes(iData), 0, 
                                 data, iProp * 16 + 8, 4);
            }
            else
            {
                // Blob
                Buffer.BlockCopy(BitConverter.GetBytes(iData), 0, 
                                 data, iProp * 16 + 12, 4);
            }

            iData = AddOffset(iData, nDataSize);
        }
        ++iProp;
    }
    return CeDbApi.CeDbNetWriteRecord(hDbase, oid,
                                      (ushort)m_arrProp.Count, data);
}

该方法虽然有点长,但并不太复杂。它首先计算将保存记录的字节数组的总大小。大小计算借助 AddOffset 函数完成,该函数正确计算所有偏移量都位于四字节边界上(无耻地借鉴了 ATL OLE DB Consumer Templates 代码)。

private int AddOffset(int nCurrent, int nAdd)
{
    int nAlign = 4,
        nRet,
        nMod;
    
    nRet = nCurrent + nAdd;
    nMod = nRet % nAlign;

    if(nMod != 0)
        nRet += nAlign - nMod;

    return nRet;
}

在计算完字节数组大小后,在第二个 for 循环中用各个属性填充它。iData 变量包含字符串或 BLOB 数据的偏移量,并通过 AddOffset 函数递增。当此循环完成时,字节数组已正确填充,可以封送到 CEDB API。但这不能直接完成。需要一点原生代码魔法。

CEDBNET_API CEOID CeDbNetWriteRecord(HANDLE     hDbase,
                                    CEOID      oidRecord,
                                    WORD       nProps,
                                    CEPROPVAL* pPropVal)
{
    CEPROPVAL* pCur  = pPropVal;
    WORD       iProp;

    //
    // Transform byte offsets into pointers
    //
    for(iProp = 0; iProp < nProps; ++iProp, ++pCur)
    {
        DWORD    dwOffset = 0;

        switch(TypeFromPropID(pCur->propid))
        {
        case CEVT_BLOB:
            dwOffset = (DWORD)pCur->val.blob.lpb;
            dwOffset += (DWORD)pPropVal;

            pCur->val.blob.lpb = (LPBYTE)dwOffset;
            break;

        case CEVT_LPWSTR:
            dwOffset = (DWORD)pCur->val.lpwstr;
            dwOffset += (DWORD)pPropVal;

            pCur->val.lpwstr = (LPWSTR)dwOffset;
            break;
        }
    }

    return CeWriteRecordProps(hDbase, oidRecord, nProps, pPropVal);
}

此原生函数执行的操作与第一个函数完全相反——它将所有偏移量转换为指针,以便 CEDB API 可以使用它们。

记录的更新和属性存储由 CeDbRecord 类处理。Write 方法可用于更新记录或创建新记录,具体取决于 oid 参数的值。值为零表示在数据库中插入新记录,而使用记录的 ID 则更新该记录。

这些方法由 CeDbRecordSet 类用于实现 Update 和 Insert 方法。Delete 方法直接调用 CEDB API。

public void Delete(CeDbRecord record)
{
    CeDbApi.CeDeleteRecord(m_hTable, record.Id);
}

public void Delete(int id)
{
    CeDbApi.CeDeleteRecord(m_hTable, id);
}

MoveFirstMoveNext 等导航方法是通过 CeDbApi.CeSeekDatabaseCeDbSeek 枚举实现的。

[Flags]
public enum CeDbSeek : uint
{
    SeekCEOID           =   1,
    SeekBeginning       =   2,
    SeekEnd             =   4,
    SeekCurrent         =   8,
    SeekValueSmaller    =  16,
    SeekValueFirstEqual =  32,
    SeekValueGreater    =  64,
    SeekValueNextEqual  = 128
}

请注意,CeDbRecordSet 对象管理打开数据库时返回的句柄。作为非托管资源,此类必须实现 IDisposable 接口。

示例项目

示例项目使用 CDEB 托管 API 编辑 Pocket PC 联系人数据库。该应用程序由一个主窗体组成,其中嵌入了一个列表视图,显示所有联系人。加载列表的代码非常直接。

private void LoadList()
{
    bool    bRead  = true;
    int     oid    = 0;
    Cursor  oldCur = Cursor.Current;

    Cursor.Current = Cursors.WaitCursor;

    listCont.Items.Clear();

    m_volume.UseSystem();

    m_table = new CeDbTable(m_volume, "Contacts Database");

    CeDbRecordSet recset = m_table.Open(CeDbOpenFlags.AutoIncrement,
                                        0x4013001F);

    listCont.BeginUpdate();
    for(bRead = true; bRead; bRead = (oid != 0))
    {
        CeDbRecord rec = recset.Read();

        oid = rec.Id;
        if(oid != 0)
        {
            ContactItem item = new ContactItem(rec);

            listCont.Items.Add(item);
        }
    }
    recset.Close();
    listCont.EndUpdate();

    Cursor.Current = oldCur;
}

这个小程序清楚地展示了 CeDbVolumeCeDbTableCeDbRecordSetCeDbRecord 是如何关联和使用的。请注意,使用自动递增标志打开数据库如何强制引擎在读取记录时自动前进记录指针。为了帮助将记录存储在列表中,ContactItem 类继承自 ListViewItem,以便存储 Contact 类的实例。联系人由 CeDbRecord 构建,并将其属性映射到 CeDbRecord 自己的 CeDbPropertyCollection 项。

该应用程序还简要展示了如何更新、插入和删除记录。

一句警告:使用此应用程序时,请务必备份设备上的联系人数据库。

© . All rights reserved.