当前位置: 移动技术网 > IT编程>开发语言>c# > Xamarin.Forms读取并展示Android和iOS通讯录 - TerminalMACS客户端

Xamarin.Forms读取并展示Android和iOS通讯录 - TerminalMACS客户端

2020年04月01日  | 移动技术网IT编程  | 我要评论

xamarin.forms读取并展示android和ios通讯录 - terminalmacs客户端

本文同步更新地址:

阅读导航:

  • 一、功能说明
  • 二、代码实现
  • 三、源码获取
  • 四、参考资料
  • 五、后面计划

一、功能说明

完整思维导图:https://github.com/dotnet9/terminalmacs/blob/master/docs/terminalmacs.xmind
xamarin.forms客户端通讯录功能

本文介绍图中右侧画红圈处的功能,即使用xamarin.forms获取和展示android和ios的通讯录信息,下面是最终效果,由于使用的是真实手机,所以联系人姓名及电话号码打码显示。

通讯录列表

并简单的进行了搜索功能处理,之所以说简单,是因为通讯录列表是全部读取出来了,搜索是直接从此列表进行过滤的。

下图来自:https://www.xamboy.com/2019/10/10/getting-phone-contacts-in-xamarin-forms/, 本功能是参考此文所写,所以直接引用文中的图片。

通讯录搜索列表

二、代码实现

1、共享库工程创建联系人实体类:contacts.cs

namespace terminalmacs.clients.app.models
{
    /// <summary>
    /// 通讯录
    /// </summary>
    public class contact
    {
        /// <summary>
        /// 获取或者设置名称
        /// </summary>
        public string name { get; set; }
        /// <summary>
        /// 获取或者设置 头像
        /// </summary>
        public string image { get; set; }
        /// <summary>
        /// 获取或者设置 邮箱地址
        /// </summary>
        public string[] emails { get; set; }
        /// <summary>
        /// 获取或者设置 手机号码
        /// </summary>
        public string[] phonenumbers { get; set; }
    }
}

2、共享库创建通讯录服务接口:icontactsservice.cs

包括:

  • 一个通讯录获取请求接口:retrievecontactsasync
  • 一个读取一条通讯结果通知事件:oncontactloaded
using system;
using system.collections.generic;
using system.threading;
using system.threading.tasks;
using terminalmacs.clients.app.models;

namespace terminalmacs.clients.app.services
{
    /// <summary>
    /// 通讯录事件参数
    /// </summary>
    public class contacteventargs:eventargs
    {
        public contact contact { get; }
        public contacteventargs(contact contact)
        {
            contact = contact;
        }
    }

    /// <summary>
    /// 通讯录服务接口,android和ios终端具体的通讯录获取服务需要继承此接口
    /// </summary>
    public interface icontactsservice
    {
        /// <summary>
        /// 读取一条数据通知
        /// </summary>
        event eventhandler<contacteventargs> oncontactloaded;
        /// <summary>
        /// 是否正在加载
        /// </summary>
        bool isloading { get; }
        /// <summary>
        /// 尝试获取所有通讯录
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        task<ilist<contact>> retrievecontactsasync(cancellationtoken? token = null);
    }
}

3、ios工程中添加通讯录服务,实现icontactsservice接口:

using contacts;
using foundation;
using system;
using system.collections.generic;
using system.io;
using system.linq;
using system.threading;
using system.threading.tasks;
using terminalmacs.clients.app.models;
using terminalmacs.clients.app.services;

namespace terminalmacs.clients.app.ios.services
{
    /// <summary>
    /// 通讯录获取服务
    /// </summary>
    public class contactsservice : nsobject, icontactsservice
    {
        const string thumbnailprefix = "thumb";

        bool requeststop = false;

        public event eventhandler<contacteventargs> oncontactloaded;

        bool _isloading = false;
        public bool isloading => _isloading;

        /// <summary>
        /// 异步请求权限
        /// </summary>
        /// <returns></returns>
        public async task<bool> requestpermissionasync()
        {
            var status = cncontactstore.getauthorizationstatus(cnentitytype.contacts);

            tuple<bool, nserror> authotization = new tuple<bool, nserror>(status == cnauthorizationstatus.authorized, null);

            if (status == cnauthorizationstatus.notdetermined)
            {
                using (var store = new cncontactstore())
                {
                    authotization = await store.requestaccessasync(cnentitytype.contacts);
                }
            }
            return authotization.item1;

        }

        /// <summary>
        /// 异步请求通讯录,此方法由界面真正调用
        /// </summary>
        /// <param name="canceltoken"></param>
        /// <returns></returns>
        public async task<ilist<contact>> retrievecontactsasync(cancellationtoken? canceltoken = null)
        {
            requeststop = false;

            if (!canceltoken.hasvalue)
                canceltoken = cancellationtoken.none;

            // 我们创建了一个十进制的taskcompletionsource
            var taskcompletionsource = new taskcompletionsource<ilist<contact>>();

            // 在cancellationtoken中注册lambda
            canceltoken.value.register(() =>
            {
                // 我们收到一条取消消息,取消taskcompletionsource.task
                requeststop = true;
                taskcompletionsource.trysetcanceled();
            });

            _isloading = true;

            var task = loadcontactsasync();

            // 等待两个任务中的第一个任务完成
            var completedtask = await task.whenany(task, taskcompletionsource.task);
            _isloading = false;

            return await completedtask;

        }

        /// <summary>
        /// 异步加载通讯录,具体的通讯录读取方法
        /// </summary>
        /// <returns></returns>
        async task<ilist<contact>> loadcontactsasync()
        {
            ilist<contact> contacts = new list<contact>();
            var haspermission = await requestpermissionasync();
            if (haspermission)
            {

                nserror error = null;
                var keystofetch = new[] { cncontactkey.phonenumbers, cncontactkey.givenname, cncontactkey.familyname, cncontactkey.emailaddresses, cncontactkey.imagedataavailable, cncontactkey.thumbnailimagedata };

                var request = new cncontactfetchrequest(keystofetch: keystofetch);
                request.sortorder = cncontactsortorder.givenname;

                using (var store = new cncontactstore())
                {
                    var result = store.enumeratecontacts(request, out error, new cncontactstorelistcontactshandler((cncontact c, ref bool stop) =>
                    {

                        string path = null;
                        if (c.imagedataavailable)
                        {
                            path = path = path.combine(path.gettemppath(), $"{thumbnailprefix}-{guid.newguid()}");

                            if (!file.exists(path))
                            {
                                var imagedata = c.thumbnailimagedata;
                                imagedata?.save(path, true);


                            }
                        }

                        var contact = new contact()
                        {
                            name = string.isnullorempty(c.familyname) ? c.givenname : $"{c.givenname} {c.familyname}",
                            image = path,
                            phonenumbers = c.phonenumbers?.select(p => p?.value?.stringvalue).toarray(),
                            emails = c.emailaddresses?.select(p => p?.value?.tostring()).toarray(),

                        };

                        if (!string.isnullorwhitespace(contact.name))
                        {
                            oncontactloaded?.invoke(this, new contacteventargs(contact));

                            contacts.add(contact);
                        }

                        stop = requeststop;

                    }));
                }
            }

            return contacts;
        }


    }
}

4、在ios工程中的info.plist文件添加通讯录权限使用说明

info.plist

5、在android工程中添加读取通讯录权限配置:androidmanifest.xml

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

完整权限配置如下

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versioncode="1" android:versionname="1.0" package="com.companyname.terminalmacs.clients.app">
	<uses-sdk android:minsdkversion="21" android:targetsdkversion="28" />
	<application android:label="terminalmacs.clients.app.android"></application>
	<uses-permission android:name="android.permission.access_network_state" />
  <uses-permission android:name="android.permission.read_contacts"/>
  <uses-permission android:name="android.permission.write_external_storage" />
</manifest>

6、在android工程中添加通讯录服务,实现icontactserver接口:contactsservice.cs

using acr.userdialogs;
using android;
using android.app;
using android.content;
using android.content.pm;
using android.database;
using android.provider;
using android.runtime;
using android.support.v4.app;
using plugin.currentactivity;
using system;
using system.collections.generic;
using system.io;
using system.linq;
using system.threading;
using system.threading.tasks;
using terminalmacs.clients.app.models;
using terminalmacs.clients.app.services;

namespace terminalmacs.clients.app.droid.services
{
    /// <summary>
    /// 通讯录获取服务
    /// </summary>
    public class contactsservice : icontactsservice
    {
        const string thumbnailprefix = "thumb";
        bool stopload = false;
        static taskcompletionsource<bool> contactpermissiontcs;
        public string tag
        {
            get
            {
                return "mainactivity";
            }
        }
        bool _isloading = false;
        public bool isloading => _isloading;
        //权限请求状态码
        public const int requestcontacts = 1239;
        /// <summary>
        /// 获取通讯录需要的请求权限
        /// </summary>
        static string[] permissionscontact = {
            manifest.permission.readcontacts
        };

        public event eventhandler<contacteventargs> oncontactloaded;
        /// <summary>
        /// 异步请求通讯录权限
        /// </summary>
        async void requestcontactspermissions()
        {
            //检查是否可以弹出申请读、写通讯录权限
            if (activitycompat.shouldshowrequestpermissionrationale(crosscurrentactivity.current.activity, manifest.permission.readcontacts)
                || activitycompat.shouldshowrequestpermissionrationale(crosscurrentactivity.current.activity, manifest.permission.writecontacts))
            {
                // 如果未授予许可,请向用户提供其他理由用户将从使用权限的附加上下文中受益。
                // 例如,如果请求先前被拒绝。
                await userdialogs.instance.alertasync("通讯录权限", "此操作需要“通讯录”权限", "确定");
            }
            else
            {
                // 尚未授予通讯录权限。直接请求这些权限。
                activitycompat.requestpermissions(crosscurrentactivity.current.activity, permissionscontact, requestcontacts);
            }
        }

        /// <summary>
        /// 收到用户响应请求权限操作后的结果
        /// </summary>
        /// <param name="requestcode"></param>
        /// <param name="permissions"></param>
        /// <param name="grantresults"></param>
        public static void onrequestpermissionsresult(int requestcode, string[] permissions, [generatedenum] android.content.pm.permission[] grantresults)
        {
            if (requestcode == requestcontacts)
            {
                // 我们请求了多个通讯录权限,因此需要检查相关的所有权限
                if (permissionutil.verifypermissions(grantresults))
                {
                    // 已授予所有必需的权限,显示联系人片段。
                    contactpermissiontcs.trysetresult(true);
                }
                else
                {
                    contactpermissiontcs.trysetresult(false);
                }

            }
        }

        /// <summary>
        /// 异步请求权限
        /// </summary>
        /// <returns></returns>
        public async task<bool> requestpermissionasync()
        {
            contactpermissiontcs = new taskcompletionsource<bool>();

            // 验证是否已授予所有必需的通讯录权限。
            if (android.support.v4.content.contextcompat.checkselfpermission(crosscurrentactivity.current.activity, manifest.permission.readcontacts) != (int)permission.granted
                || android.support.v4.content.contextcompat.checkselfpermission(crosscurrentactivity.current.activity, manifest.permission.writecontacts) != (int)permission.granted)
            {
                // 尚未授予通讯录权限。
                requestcontactspermissions();
            }
            else
            {
                // 已授予通讯录权限。
                contactpermissiontcs.trysetresult(true);
            }

            return await contactpermissiontcs.task;
        }

        /// <summary>
        /// 异步请求通讯录,此方法由界面真正调用
        /// </summary>
        /// <param name="canceltoken"></param>
        /// <returns></returns>
        public async task<ilist<contact>> retrievecontactsasync(cancellationtoken? canceltoken = null)
        {
            stopload = false;

            if (!canceltoken.hasvalue)
                canceltoken = cancellationtoken.none;

            // 我们创建了一个十进制的taskcompletionsource
            var taskcompletionsource = new taskcompletionsource<ilist<contact>>();

            // 在cancellationtoken中注册lambda
            canceltoken.value.register(() =>
            {
                // 我们收到一条取消消息,取消taskcompletionsource.task
                stopload = true;
                taskcompletionsource.trysetcanceled();
            });

            _isloading = true;

            var task = loadcontactsasync();

            // 等待两个任务中的第一个任务完成
            var completedtask = await task.whenany(task, taskcompletionsource.task);
            _isloading = false;

            return await completedtask;
        }

        /// <summary>
        /// 异步加载通讯录,具体的通讯录读取方法
        /// </summary>
        /// <returns></returns>
        async task<ilist<contact>> loadcontactsasync()
        {
            ilist<contact> contacts = new list<contact>();
            var haspermission = await requestpermissionasync();
            if (!haspermission)
            {
                return contacts;
            }

            var uri = contactscontract.contacts.contenturi;
            var ctx = application.context;
            await task.run(() =>
            {
                // 暂时只请求通讯录id、displayname、photothumbnailuri,可以扩展
                var cursor = ctx.applicationcontext.contentresolver.query(uri, new string[]
                {
                        contactscontract.contacts.interfaceconsts.id,
                        contactscontract.contacts.interfaceconsts.displayname,
                        contactscontract.contacts.interfaceconsts.photothumbnailuri
                }, null, null, $"{contactscontract.contacts.interfaceconsts.displayname} asc");
                if (cursor.count > 0)
                {
                    while (cursor.movetonext())
                    {
                        var contact = createcontact(cursor, ctx);

                        if (!string.isnullorwhitespace(contact.name))
                        {
                            // 读取出一条,即通知界面展示
                            oncontactloaded?.invoke(this, new contacteventargs(contact));
                            contacts.add(contact);
                        }

                        if (stopload)
                            break;
                    }
                }
            });

            return contacts;

        }

        /// <summary>
        /// 读取一条通讯录数据
        /// </summary>
        /// <param name="cursor"></param>
        /// <param name="ctx"></param>
        /// <returns></returns>
        contact createcontact(icursor cursor, context ctx)
        {
            var contactid = getstring(cursor, contactscontract.contacts.interfaceconsts.id);

            var numbers = getnumbers(ctx, contactid);
            var emails = getemails(ctx, contactid);

            var uri = getstring(cursor, contactscontract.contacts.interfaceconsts.photothumbnailuri);
            string path = null;
            if (!string.isnullorempty(uri))
            {
                try
                {
                    using (var stream = android.app.application.context.contentresolver.openinputstream(android.net.uri.parse(uri)))
                    {
                        path = path.combine(path.gettemppath(), $"{thumbnailprefix}-{guid.newguid()}");
                        using (var fstream = new filestream(path, filemode.create))
                        {
                            stream.copyto(fstream);
                            fstream.close();
                        }

                        stream.close();
                    }


                }
                catch (exception ex)
                {
                    system.diagnostics.debug.writeline(ex);
                }

            }
            var contact = new contact
            {
                name = getstring(cursor, contactscontract.contacts.interfaceconsts.displayname),
                emails = emails,
                image = path,
                phonenumbers = numbers,
            };

            return contact;
        }

        /// <summary>
        /// 读取联系人电话号码
        /// </summary>
        /// <param name="ctx"></param>
        /// <param name="contactid"></param>
        /// <returns></returns>
        string[] getnumbers(context ctx, string contactid)
        {
            var key = contactscontract.commondatakinds.phone.number;

            var cursor = ctx.applicationcontext.contentresolver.query(
                contactscontract.commondatakinds.phone.contenturi,
                null,
                contactscontract.commondatakinds.phone.interfaceconsts.contactid + " = ?",
                new[] { contactid },
                null
            );

            return readcursoritems(cursor, key)?.toarray();
        }

        /// <summary>
        /// 读取联系人邮箱地址
        /// </summary>
        /// <param name="ctx"></param>
        /// <param name="contactid"></param>
        /// <returns></returns>
        string[] getemails(context ctx, string contactid)
        {
            var key = contactscontract.commondatakinds.email.interfaceconsts.data;

            var cursor = ctx.applicationcontext.contentresolver.query(
                contactscontract.commondatakinds.email.contenturi,
                null,
                contactscontract.commondatakinds.email.interfaceconsts.contactid + " = ?",
                new[] { contactid },
                null);

            return readcursoritems(cursor, key)?.toarray();
        }

        ienumerable<string> readcursoritems(icursor cursor, string key)
        {
            while (cursor.movetonext())
            {
                var value = getstring(cursor, key);
                yield return value;
            }

            cursor.close();
        }

        string getstring(icursor cursor, string key)
        {
            return cursor.getstring(cursor.getcolumnindex(key));
        }

    }
}

需要添加 plugin.currentactivityacr.userdialogs 包。

7、android工程添加权限处理判断类

permission.util

using android.content.pm;

namespace terminalmacs.clients.app.droid
{
    public static class permissionutil
    {
        /**
		* 通过验证给定数组中的每个条目的值是否为permission.granted,检查是否已授予所有给定权限。
		*
		* see activity#onrequestpermissionsresult (int, string[], int[])
		*/
        public static bool verifypermissions(permission[] grantresults)
        {
            // 必须至少检查一个结果.
            if (grantresults.length < 1)
                return false;

            // 验证是否已授予每个必需的权限,否则返回false.
            foreach (permission result in grantresults)
            {
                if (result != permission.granted)
                {
                    return false;
                }
            }
            return true;
        }
    }
}

mainactivity.onrequestpermissionresult是权限申请结果处理函数,在此函数中调用contactsservice.onrequestpermissionsresult通知通讯录服务权限处理结果。

mainactivity.cs

using acr.userdialogs;
using android.app;
using android.content.pm;
using android.os;
using android.runtime;
using terminalmacs.clients.app.droid.services;
using terminalmacs.clients.app.services;

namespace terminalmacs.clients.app.droid
{
    [activity(label = "terminalmacs.clients.app", icon = "@mipmap/icon", theme = "@style/maintheme", mainlauncher = true, configurationchanges = configchanges.screensize | configchanges.orientation)]
    public class mainactivity : global::xamarin.forms.platform.android.formsappcompatactivity
    {
        icontactsservice contactsservice = new contactsservice();
        protected override void oncreate(bundle savedinstancestate)
        {
            tablayoutresource = resource.layout.tabbar;
            toolbarresource = resource.layout.toolbar;

            base.oncreate(savedinstancestate);

            xamarin.essentials.platform.init(this, savedinstancestate);
            global::xamarin.forms.forms.init(this, savedinstancestate);
            userdialogs.init(() => this);

            // 将通讯录服务实例传递给共享库,由共享库使用读取通讯录接口
            loadapplication(new app(contactsservice));
        }
        public override void onrequestpermissionsresult(int requestcode, string[] permissions, [generatedenum] android.content.pm.permission[] grantresults)
        {
            xamarin.essentials.platform.onrequestpermissionsresult(requestcode, permissions, grantresults);

            // 通讯录服务处理权限请求结果
            contactsservice.onrequestpermissionsresult(requestcode, permissions, grantresults);
            
            base.onrequestpermissionsresult(requestcode, permissions, grantresults);
        }
    }
}

8、创建通讯录viewmodel,并使用通讯录服务

using system;
using system.collections;
using system.collections.generic;
using system.collections.objectmodel;
using system.linq;
using system.threading.tasks;
using system.windows.input;
using terminalmacs.clients.app.models;
using terminalmacs.clients.app.services;
using xamarin.forms;

namespace terminalmacs.clients.app.viewmodels
{
    /// <summary>
    /// 通讯录viewmodel
    /// </summary>
    public class contactviewmodel : baseviewmodel
    {
        /// <summary>
        /// 通讯录服务接口
        /// </summary>
        icontactsservice _contactservice;
        /// <summary>
        /// 标题
        /// </summary>
        public new string title => "通讯录";
        private string _searchtext;
        /// <summary>
        /// 搜索关键字
        /// </summary>
        public string searchtext
        {
            get { return _searchtext; }
            set
            {
                setproperty(ref _searchtext, value);
            }
        }
        /// <summary>
        /// 通讯录搜索命令
        /// </summary>
        public icommand raisesearchcommand { get; }
        /// <summary>
        /// 通讯录列表
        /// </summary>
        public observablecollection<contact> contacts { get; set; }
        private list<contact> _filteredcontacts;
        /// <summary>
        /// 通讯录过滤列表
        /// </summary>
        public list<contact> filteredcontacts

        {
            get { return _filteredcontacts; }
            set
            {
                setproperty(ref _filteredcontacts, value);
            }
        }
        public contactviewmodel(icontactsservice contactservice)
        {
            _contactservice = contactservice;
            contacts = new observablecollection<contact>();
            xamarin.forms.bindingbase.enablecollectionsynchronization(contacts, null, observablecollectioncallback);
            _contactservice.oncontactloaded += oncontactloaded;
            loadcontacts();
            raisesearchcommand = new command(raisesearchhandle);
        }

        /// <summary>
        /// 过滤通讯录
        /// </summary>
        void raisesearchhandle()
        {
            if (string.isnullorempty(searchtext))
            {
                filteredcontacts = contacts.tolist();
                return;
            }

            func<contact, bool> checkcontact = (s) =>
            {
                if (!string.isnullorwhitespace(s.name) && s.name.tolower().contains(searchtext.tolower()))
                {
                    return true;
                }
                else if (s.phonenumbers.length > 0 && s.phonenumbers.tolist().exists(cu => cu.tostring().contains(searchtext)))
                {
                    return true;
                }
                return false;
            };
            filteredcontacts = contacts.tolist().where(checkcontact).tolist();
        }

        /// <summary>
        /// bindingbase.enablecollectionsynchronization 为集合启用跨线程更新
        /// </summary>
        /// <param name="collection"></param>
        /// <param name="context"></param>
        /// <param name="accessmethod"></param>
        /// <param name="writeaccess"></param>
        void observablecollectioncallback(ienumerable collection, object context, action accessmethod, bool writeaccess)
        {
            // `lock` ensures that only one thread access the collection at a time
            lock (collection)
            {
                accessmethod?.invoke();
            }
        }

        /// <summary>
        /// 收到事件通知,读取一条通讯录信息
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void oncontactloaded(object sender, contacteventargs e)
        {
            contacts.add(e.contact);
            raisesearchhandle();
        }

        /// <summary>
        /// 异步读取终端通讯录
        /// </summary>
        /// <returns></returns>
        async task loadcontacts()
        {
            try
            {
                await _contactservice.retrievecontactsasync();
            }
            catch (taskcanceledexception)
            {
                console.writeline("任务已经取消");
            }
        }
    }
}

9、添加通讯录页面展示通讯录数据

<?xml version="1.0" encoding="utf-8" ?>
<contentpage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:ios="clr-namespace:xamarin.forms.platformconfiguration.iosspecific;assembly=xamarin.forms.core"
             mc:ignorable="d"
             title="{binding title}"
             x:class="terminalmacs.clients.app.views.contactpage"
             ios:page.usesafearea="true">
    <contentpage.content>
        <stacklayout>
            <searchbar x:name="filtertext"
                        heightrequest="40"
                        text="{binding searchtext}"
                       searchcommand="{binding raisesearchcommand}"/>
            <listview   itemssource="{binding filteredcontacts}"
                        hasunevenrows="true">
                <listview.itemtemplate>
                    <datatemplate>
                        <viewcell>
                            <stacklayout padding="10"
                                         orientation="horizontal">
                                <image  source="{binding image}"
                                        verticaloptions="center"
                                        x:name="image"
                                        aspect="aspectfit"
                                        heightrequest="60"/>
                                <stacklayout verticaloptions="center">
                                    <label text="{binding name}"
                                       fontattributes="bold"/>
                                    <label text="{binding phonenumbers[0]}"/>
                                    <label text="{binding emails[0]}"/>
                                </stacklayout>
                            </stacklayout>
                        </viewcell>
                    </datatemplate>
                </listview.itemtemplate>
            </listview>
        </stacklayout>
    </contentpage.content>
</contentpage>

三、源码获取

已编译的android客户端:

  • 3.ios读取通讯录功能代码也已添加,但由于本人没有ios测试环境,所以未验证,有条件的朋友可以测试下ios的通讯录读取功能,如果代码不起作用,可参考本文参考的文章检查ios代码。

四、参考资料

getting phone contacts in xamarin forms:

参考文章末尾有源代码链接。

五、后面计划

xamarin.forms客户端基本信息获取,比如imei、imsi、本机号码、mac地址等。

如对本文有疑问, 点击进行留言回复!!

相关文章:

验证码:
移动技术网