• [转][osg]关于PagedLOD 加载卸载机制


    你的PagedLOD 为什么没有卸载

    转自:http://bbs.osgchina.org/forum.php?mod=viewthread&tid=7612&highlight=PagedLOD&_dsign=ed8fb143

    分页对于大型场景而言是一个必不可少的调度渲染技术。当你发现osg自身就带有PagedLOD 功能时,何尝不兴致冲冲的立即使用。可是,在使用时突然发现只有加载没有卸载,内存随着运行不断攀升,过期的PagedLOD 节点竟然没有被osg踢出内存?苦恼啊,纠结啊?难道是bug?其实不然,osg的PagedLOD 技术已十分成熟,那么究竟是何原因造成的?焦躁的你请耐心阅读
    1、导致PagedLOD 不被卸载的第一个原因
    在使用osgViewr::Viewer::setSceneData设置场景给Viewr之前,没有把你的PagedLOD 节点加入到场景根节点下。考虑如下两种情况:

    osg::Group * root=new osg::Group;
    osg::PagedLOD * lod1=new osg::PagedLOD;
    lod1->setFileName(0,"cow.osg");  //添加子节点
    lod1->setRange(0,0,10);        //设置子节点显示范围
    
    root->addChild(lod1);
    viewer->setSceneData(root /*createTeapot()*/ );
    osg::Group * root=new osg::Group;
    osg::PagedLOD * lod1=new osg::PagedLOD;
    lod1->setFileName(0,"cow.osg");
    lod1->setRange(0,0,10);
    
    viewer->setSceneData(root /*createTeapot()*/ );
    root->addChild(lod1);

    两种情况下lod1节点都会在过期时删除吗?

    不是!

    只有第一种情况中的PagedLOD 会在过期时从内存中卸载。

    说起原因,就不得不告诉你一个setSceneData函数的小秘密。看下面代码:

    void Scene::setSceneData(osg::Node* node)
    {
    _sceneData = node;
    
    if (_databasePager.valid())
    { 
    // register any PagedLOD that need to be tracked in the scene graph
    if (node) _databasePager->registerPagedLODs(node);
    }
    }

    在setSceneDAta时向DatabasePagerr注册了场景中的所有PagedLOD 节点。

    如果此时场景中还没有设置PagedLOD 节点,那么很抱歉,即使你在随后设置了,DatabasePager中注册的PagedLOD 节点依然是当时注册的那些,你随后设置的只能是计划外人口,黑户,对不起DatabasePager是不管这些黑PagedLOD 的死活。

    既然DatabasePager不管这些PagedLOD 节点的死活,那么为什么这些PagedLOD 节点可以被动态加载呢?

    因为PagedLOD 节点的动态加载请求是PagedLOD 节点自身发出,而卸载则是由DatabasePager管理!请查看PagedLOD 的traverse函数,它会告诉你一切。
    讲到这里,也许你已经心中有数,使用PagedLOD 节点时应该预先设置然后再setSceneData。

    如果我想在运行过程中添加PagedLOD 节点又想让其可以卸载该怎么办呢?

    答案就是改变时请注意调用DatabasePager::registerPagedLODs(node);函数注册户口。

    2、导致PagedLOD 节点不能卸载的第二个原因:

    如果你使用PagedLOD 节点完全避免1中提到的问题,但是你的PagedLOD 节点依然顽固的赖在内存中不走,那么请你看看这里。

    我们知道分页的功能与内存有关,那么内存不够时PagedLOD 节点就应该自动退出内存才是,为什么他还赖在那里呢?

    答案是,他并不知道内存不够用了,需要你告诉他!DatabasePager::setTargetMaximumNumberOfPageLOD函数或环境变量OSG_MAX_PAGEDLOD就是干这个的。他告诉DatabasePager我的电脑内存有限只能容纳指定数量的PagedLOD ,超出这个数的过期PagedLOD 就让他滚蛋吧。

    也许你会问PagedLOD 个数和内存使用情况有个毛关系?

    确实有关系,在你进行PagedLOD 分页规划时你的PagedLOD 节点的每一级都有一定的大小,那么这个大小和个数相乘就是要占用的总内存。

    值得一提的是osg中此数默认数量是300,osg觉得你的电脑配置很高!

    OSG动态调度DatabasePager,pagedLOD

    转自:https://blog.csdn.net/u012130706/article/details/77175586

    使用动态调度的原因
    当用户需要浏览的数据量很大,比如地形模拟、虚拟小区和城市等的时候,会对计算机系统产生极大的负担。
    在内存中可能要存储海量数据,这些海量数据指的是数百GB甚至TB级别的数据(例如中国境内的山形地貌等),这些不可能全部载入内存中,就算未来的计算机能够将它们一次性读入,也已经损耗了太多的系统性能。

    动态调度的原理
    在显示当前视域中的场景元素(可见元素)的同时,预判断下一步可能载入的数据(预可见元素),以及那些短时间内不可能被看到的对象(不可见元素),从而作出正确的数据加载和卸载处理,确保内存中始终维持有限的数据额度,并且不会因此造成场景浏览时重要信息的丢失或者过于迟缓。
    数据的动态调度可以使用多线程的工作方式,使数据的动态调度和场景的实时绘制同时进行。由于动态数据的加载/卸载可能影响到场景树的结构,因此这一工作需要在场景更新的阶段完成,以免影响到裁剪和绘制的过程。

    动态调度的过程
    (1) 删除过期的场景数据:过期数据指的是那些长时间没有处于用户视域内,并且有理由认为它们不会立即显现的场景元素。场景的更新遍历函数负责将检索到的过期对象收集并送入相应的过期对象列表;而列表中的数据通常可以在数据线程中统一予以删除。
    (2) 获取新的数据加载请求:请求加载的可能是新的数据信息,也可能是已有的场景数据(曾经从“当前页面”中去除,更新又回到“当前页面”中);数据可能是本地的文件,也可能来自网络;从网络下载的数据往往还需要缓存在本地磁盘上。这些都需要在数据线程中一一加以判断。
    (3) 编译加载的数据:有些数据如果提前进行编译可以有效地提升效率,例如为几何体数据创建显示列表,以及将纹理对象提前加载到纹理内存。虽然OSG同样可以在负责渲染的主进程中根据用户需要执行这些工作,但是那样有可能造成帧的延迟。例如,一个大型场景的显示和调度过程中,如果大量的地块数据同时被加载入内存,那么下一帧的数据编译任务将变得十分繁重,继而造成较为严重的帧延迟。此时如果由数据线程负责预编译的工作,则可以在一定程度上缓解这一压力。
    (4) 将加载的数据合并至场景图形:直接由数据线程来完成这一工作显然是不合适的,因为系统主进程不知道当DatabaseThread线程视图操作场景中的结点时,OSG的渲染器在做些什么。最好的方法是将读入的数据先保存在一个列表中,并且由仿真循环负责获取和执行合并新节点的操作。

    DatabasePager与PagedLOD
    在OSG中,osgDB::DatabasePager类负责执行场景动态调度的工作。与osgDB::DatabasePager搭配使用的是PagedLOD和osg::ProxyNode。这里主要讨论PagedLOD。
    PagedLOD既具有将大量数据或者模型使用细节层次(LOD)原则划分的特性,又有动态调度以保证渲染效率和内存管理的特性。

    代码示例

    #include <osg/ShapeDrawable>
    #include <osg/Geode>
    #include <osg/PagedLOD>
    #include <osgViewer/Viewer>
    
    osg::Geode* createBox(const osg::Vec3& center, float width)
    {
    osg::ref_ptr<osg::Geode> geode = new osg::Geode;
    geode->addDrawable(
    new osg::ShapeDrawable(new osg::Box(center, width)));
    return geode.release();
    }
    
    osg::Group* createPagedLOD(int row, int col)
    {
    osg::ref_ptr<osg::Group> root = new osg::Group;
    char buffer[5] = "";
    for (int i = 0; i<row; i++)
    {
    for (int j = 0; j<col; j++)
    {
    std::string filename = "cow.osg.";
    #ifdef _WIN32
    _itoa_s(i * 10, buffer, 5, 10);
    filename += buffer; filename += ",";
    _itoa_s(j * 10, buffer, 5, 10);
    filename += buffer; filename += ",0.trans";
    #else
    gcvt(i * 10, 5, buffer);
    filename += buffer; filename += ",";
    gcvt(j * 10, 5, buffer);
    filename += buffer; filename += ",0.trans";
    #endif
    osg::ref_ptr<osg::PagedLOD> lod = new osg::PagedLOD;
    lod->setCenter(osg::Vec3(i * 10, j * 10, 0.0));
    lod->addChild(createBox(osg::Vec3(i * 10, j * 10, 0.0), 1), 200.0, FLT_MAX);
    lod->setFileName(1, filename);
    lod->setRange(1, 0.0, 200.0);
    root->addChild(lod.get());
    }
    }
    return root.release();
    }
    
    int main(int argc, char** argv)
    {
    osgViewer::Viewer viewer;
    viewer.setSceneData(createPagedLOD(30, 30));
    return viewer.run();
    }
    

      

    运行结果图:

    深入理解osg::PagedLOD

    转自:https://blog.csdn.net/qq_16123279/article/details/82665053 

    1.先看看继承关系:
    PagedLOD继承了LOD继承了Group继承了Node;

    2.简单说说OSG的每一帧干的事:
    OSG其实很简单就是封装了一个循环,在这个循环里面,osg不断调用各种NodeVisitor,去处理加入场景的各个Node。

    void ViewerBase::frame(double simulationTime)
    {
    if (_done) return;

    // OSG_NOTICE<<std::endl<<"CompositeViewer::frame()"<<std::endl<<std::endl;

    if (_firstFrame)
    {
    viewerInit();

    if (!isRealized())
    {
    realize();
    }

    _firstFrame = false;
    }
    advance(simulationTime);

    eventTraversal();
    updateTraversal();
    renderingTraversals();
    }
    其中只有renderingTraversals();是viewerBase自己实现的,eventTraversal()和 updateTraversal();交由CompositeViewer和Viewer实现。

    3.访问器工作原理
    这三大遍历函数里面又是很多具体的实现,其中各种访问器(NodeVisitor的apply(Node* pNode))和各种Node(Node的traverse(NodeVisitor* pVisitor))在里面扮演了重要的角色。首先apply某一个node,然后apply里面可以写对应的操作,然后node的traverse可以应用一个遍历器写上某个具体node的功能,node还有Node::accept(NodeVisitor& nv)里面如果不写什么的话基本就是nv.apply(*this);我们来看一下具体的代码:

    Node里面遍历相关函数
    /** Visitor Pattern : calls the apply method of a NodeVisitor with this node's type.*/
    virtual void accept(NodeVisitor& nv);
    /** Traverse upwards : calls parents' accept method with NodeVisitor.*/
    virtual void ascend(NodeVisitor& nv);
    /** Traverse downwards : calls children's accept method with NodeVisitor.*/
    virtual void traverse(NodeVisitor& /*nv*/) {}

    void Node::accept(NodeVisitor& nv)
    {
    if (nv.validNodeMask(*this))
    {
    nv.pushOntoNodePath(this);
    nv.apply(*this);
    nv.popFromNodePath();
    }
    }

    void Node::ascend(NodeVisitor& nv)
    {
    std::for_each(_parents.begin(),_parents.end(),NodeAcceptOp(nv));
    }
    可以看到 accpt就是调用一个visitor的apply()函数,traverse函数没有实现,因为这个函数代表了某个节点的特性,比如Group节点,LOD节点,PagedLOD节点等等,这些节点的traverse实现都是不同的。而ascend会像父节点遍历,是traverse的反向。向孩子或向父节点遍历是visitor说了算。

    NodeVisitor
    inline void NodeVisitor::traverse(Node& node)
    {
    if (_traversalMode==TRAVERSE_PARENTS) node.ascend(*this);
    else if (_traversalMode!=TRAVERSE_NONE) node.traverse(*this);
    }

    void NodeVisitor::apply(Node& node)
    {
    traverse(node);
    }

    void NodeVisitor::apply(Drawable& drawable)
    {
    apply(static_cast<Node&>(drawable));
    }

    void NodeVisitor::apply(Geometry& drawable)
    {
    apply(static_cast<Drawable&>(drawable));
    }

    void NodeVisitor::apply(Geode& node)
    {
    apply(static_cast<Group&>(node));
    }

    void NodeVisitor::apply(Billboard& node)
    {
    apply(static_cast<Geode&>(node));
    }

    void NodeVisitor::apply(Group& node)
    {
    apply(static_cast<Node&>(node));
    }
    可以看到NodeVisitor里面出了对Node基本遍历做了处理,其他的节点均未实现,需要的什么功能,我们可以继承来解决,调用的时候有两种方式:

    Node.accept(NodeVisitor);//最好的办法
    NodeVisitor.apply(Node);//也行(不推荐)

    所以结合以上的说了一大堆,我们明白一个节点的特性应该去他的traverse函数里面去看,然后我们来看看PagedLOD的特性:

    LOD特性
    详细代码可以自己去对应的源码部分看,这里只是说一下其算法步骤:

    1.根据用户设定的距离模式(视点到包围球中心距离,在屏幕上占有的像素大小),计算一个距离;

    2.判断_rangeList的尺寸与numChildren,如果小于孩子数量就让numChildren与_randgelist的数量相等

    3.遍历numChildren,在范围内的就accep(nv),不accept(nv)的节点将不会被渲染遍历到就不会被渲染出来
    相关代码:
    switch(nv.getTraversalMode())
    {
    case(NodeVisitor::TRAVERSE_ALL_CHILDREN):
    std::for_each(_children.begin(),_children.end(),NodeAcceptOp(nv));
    break;
    case(NodeVisitor::TRAVERSE_ACTIVE_CHILDREN):
    {
    float required_range = 0;
    if (_rangeMode==DISTANCE_FROM_EYE_POINT)
    {
    required_range = nv.getDistanceToViewPoint(getCenter(),true);
    }
    else
    {
    osg::CullStack* cullStack = nv.asCullStack();
    if (cullStack && cullStack->getLODScale())
    {
    required_range = cullStack->clampedPixelSize(getBound()) / cullStack->getLODScale();
    }
    else
    {
    // fallback to selecting the highest res tile by
    // finding out the max range
    for(unsigned int i=0;i<_rangeList.size();++i)
    {
    required_range = osg::maximum(required_range,_rangeList[i].first);
    }
    }
    }

    unsigned int numChildren = _children.size();
    if (_rangeList.size()<numChildren) numChildren=_rangeList.size();

    for(unsigned int i=0;i<numChildren;++i)
    {
    if (_rangeList[i].first<=required_range && required_range<_rangeList[i].second)
    {
    _children[i]->accept(nv);
    }
    }
    break;
    }
    PagedLOD
    pagedLOD继承了LOD其traveser比LOD复杂得多,先看看算法步骤:

    1.如果遍历器类型是CULL_VISITOR,就把该遍历器上一帧遍历完成后的帧数记录下来(意思就是保存上一帧是第几帧)。

    2.对于每一个孩子计算其距离(required_range),跟LOD的相同。

    3.循环_rangeList(这个东西保存了所有孩子的范围),判断刚才计算到的required_range是否在某个个_rangeList里面,如果范围链的尺寸没有超过当前孩子的数量就accept(显示) 。

    bool updateTimeStamp = nv.getVisitorType()==osg::NodeVisitor::CULL_VISITOR;
    int lastChildTraversed = -1;
    bool needToLoadChild = false;
    for(unsigned int i=0;i<_rangeList.size();++i)
    {
    if (_rangeList[i].first<=required_range && required_range<_rangeList[i].second)
    {
    if (i<_children.size())
    {
    if (updateTimeStamp)//如果是裁剪遍历器
    {
    _perRangeDataList[i]._timeStamp=timeStamp;//记录相对时间
    _perRangeDataList[i]._frameNumber=frameNumber;//记录帧数
    }
    //如果请求的距离在节点的可见范围内,且该节点存在,
    //那么渲染该节点,且记录该节点所在的索引
    _children[i]->accept(nv);
    lastChildTraversed = (int)i;
    }
    else
    {
    //如果请求距离在节点范围内,但是该组下没有这个节点那么就加载
    needToLoadChild = true;
    }
    }
    }
    4.加载之前消失的节点
    if (needToLoadChild)
    {
    unsigned int numChildren = _children.size();

    //lastChildTraversed记录了最后一个被for遍历渲染节点的索引
    //这个判断的意思就是:
    //1.组里面有节点
    //2.组里面被渲染的最后一个节点,
    //但不是组里面最后一个节点。(这里可能是上面accept会漏然后做的补充)
    //条件1、2成立为真
    if (numChildren > 0 && ((int)numChildren - 1) != lastChildTraversed)
    {
    //如果访问器是筛选访问器
    if (updateTimeStamp)
    {
    //记录渲染开始到此刻的总时间
    _perRangeDataList[numChildren - 1]._timeStamp = timeStamp;
    //记录渲染开始到此刻的总帧数
    _perRangeDataList[numChildren - 1]._frameNumber = frameNumber;
    }
    //渲染组里面现有的最后一个节点,因为lastChildTraversed不是组里的最后一个
    _children[numChildren - 1]->accept(nv);
    }

    //从磁盘加载节点
    //@@_disableExternalChildrenPaging用户可以通过设置这个变量来禁用加载
    //@@nv.getDatabaseRequestHandler()默认就是osgDB::DatabasePager
    //@@numChildren < _perRangeDataList.size()有被卸载掉的节点
    if (!_disableExternalChildrenPaging &&
    nv.getDatabaseRequestHandler() &&
    numChildren < _perRangeDataList.size())
    {
    // compute priority from where abouts in the required range the distance falls.
    float priority = (_rangeList[numChildren].second - required_range) / (_rangeList[numChildren].second - _rangeList[numChildren].first);

    // invert priority for PIXEL_SIZE_ON_SCREEN mode
    if (_rangeMode == PIXEL_SIZE_ON_SCREEN)
    {
    priority = -priority;
    }

    // modify the priority according to the child's priority offset and scale.
    priority = _perRangeDataList[numChildren]._priorityOffset + priority * _perRangeDataList[numChildren]._priorityScale;

    //_databasePath一个字符串存放文件夹路径,
    //就是说需要用pagedLOD加载的模型节点,可以统一放到一个文件夹下
    //然后设置了_databasePath直接用模型的文件名就行了,减少内存占用
    if (_databasePath.empty())
    {
    nv.getDatabaseRequestHandler()->requestNodeFile(_perRangeDataList[numChildren]._filename, nv.getNodePath(), priority, nv.getFrameStamp(), _perRangeDataList[numChildren]._databaseRequest, _databaseOptions.get());
    }
    else
    {
    // 将DabasePath预置到子文件名。
    //requestNodeFile是个重点,下面分析
    nv.getDatabaseRequestHandler()->requestNodeFile(_databasePath + _perRangeDataList[numChildren]._filename, nv.getNodePath(), priority, nv.getFrameStamp(), _perRangeDataList[numChildren]._databaseRequest, _databaseOptions.get());
    }
    }

    }
    requestNodeFile
    void DatabasePager::requestNodeFile(const std::string& fileName, osg::NodePath& nodePath,
    float priority, const osg::FrameStamp* framestamp,
    osg::ref_ptr<osg::Referenced>& databaseRequestRef,
    const osg::Referenced* options)
    {
    osgDB::Options* loadOptions = dynamic_cast<osgDB::Options*>(const_cast<osg::Referenced*>(options));
    if (!loadOptions)
    {
    loadOptions = Registry::instance()->getOptions();
    }
    else
    {
    // OSG_NOTICE<<"options from requestNodeFile "<<std::endl;
    }


    if (!_acceptNewRequests) return;


    if (nodePath.empty())
    {
    OSG_NOTICE << "Warning: DatabasePager::requestNodeFile(..) passed empty NodePath, so nowhere to attach new subgraph to." << std::endl;
    return;
    }

    osg::Group* group = nodePath.back()->asGroup();
    if (!group)
    {
    OSG_NOTICE << "Warning: DatabasePager::requestNodeFile(..) passed NodePath without group as last node in path, so nowhere to attach new subgraph to." << std::endl;
    return;
    }

    osg::Node* terrain = 0;
    for (osg::NodePath::reverse_iterator itr = nodePath.rbegin();
    itr != nodePath.rend();
    ++itr)
    {
    if ((*itr)->asTerrain()) terrain = *itr;
    }

    double timestamp = framestamp ? framestamp->getReferenceTime() : 0.0;
    unsigned int frameNumber = framestamp ? framestamp->getFrameNumber() : static_cast<unsigned int>(_frameNumber);

    // #define WITH_REQUESTNODEFILE_TIMING
    #ifdef WITH_REQUESTNODEFILE_TIMING
    osg::Timer_t start_tick = osg::Timer::instance()->tick();
    static int previousFrame = -1;
    static double totalTime = 0.0;

    if (previousFrame != frameNumber)
    {
    OSG_NOTICE << "requestNodeFiles for " << previousFrame << " time = " << totalTime << std::endl;

    previousFrame = frameNumber;
    totalTime = 0.0;
    }
    #endif

    // search to see if filename already exist in the file loaded list.
    bool foundEntry = false;

    if (databaseRequestRef.valid())
    {
    DatabaseRequest* databaseRequest = dynamic_cast<DatabaseRequest*>(databaseRequestRef.get());
    bool requeue = false;
    if (databaseRequest)
    {
    OpenThreads::ScopedLock<OpenThreads::Mutex> drLock(_dr_mutex);
    if (!(databaseRequest->valid()))
    {
    OSG_INFO << "DatabaseRequest has been previously invalidated whilst still attached to scene graph." << std::endl;
    databaseRequest = 0;
    }
    else
    {
    OSG_INFO << "DatabasePager::requestNodeFile(" << fileName << ") updating already assigned." << std::endl;


    databaseRequest->_valid = true;
    databaseRequest->_frameNumberLastRequest = frameNumber;
    databaseRequest->_timestampLastRequest = timestamp;
    databaseRequest->_priorityLastRequest = priority;
    ++(databaseRequest->_numOfRequests);

    foundEntry = true;

    if (databaseRequestRef->referenceCount() == 1)
    {
    OSG_INFO << "DatabasePager::requestNodeFile(" << fileName << ") orphaned, resubmitting." << std::endl;

    databaseRequest->_frameNumberLastRequest = frameNumber;
    databaseRequest->_timestampLastRequest = timestamp;
    databaseRequest->_priorityLastRequest = priority;
    databaseRequest->_group = group;
    databaseRequest->_terrain = terrain;
    databaseRequest->_loadOptions = loadOptions;
    databaseRequest->_objectCache = 0;
    requeue = true;
    }

    }
    }
    if (requeue)
    _fileRequestQueue->add(databaseRequest);
    }

    if (!foundEntry)
    {
    OSG_INFO << "In DatabasePager::requestNodeFile(" << fileName << ")" << std::endl;

    OpenThreads::ScopedLock<OpenThreads::Mutex> lock(_fileRequestQueue->_requestMutex);

    if (!databaseRequestRef.valid() || databaseRequestRef->referenceCount() == 1)
    {
    osg::ref_ptr<DatabaseRequest> databaseRequest = new DatabaseRequest;

    databaseRequestRef = databaseRequest.get();

    databaseRequest->_valid = true;
    databaseRequest->_fileName = fileName;
    databaseRequest->_frameNumberFirstRequest = frameNumber;
    databaseRequest->_timestampFirstRequest = timestamp;
    databaseRequest->_priorityFirstRequest = priority;
    databaseRequest->_frameNumberLastRequest = frameNumber;
    databaseRequest->_timestampLastRequest = timestamp;
    databaseRequest->_priorityLastRequest = priority;
    databaseRequest->_group = group;
    databaseRequest->_terrain = terrain;
    databaseRequest->_loadOptions = loadOptions;
    databaseRequest->_objectCache = 0;

    _fileRequestQueue->addNoLock(databaseRequest.get());
    }
    }

    if (!_startThreadCalled)
    {
    OpenThreads::ScopedLock<OpenThreads::Mutex> lock(_run_mutex);

    if (!_startThreadCalled)
    {
    OSG_INFO << "DatabasePager::startThread()" << std::endl;

    if (_databaseThreads.empty())
    {
    setUpThreads(
    osg::DisplaySettings::instance()->getNumOfDatabaseThreadsHint(),
    osg::DisplaySettings::instance()->getNumOfHttpDatabaseThreadsHint());
    }

    _startThreadCalled = true;
    _done = false;

    for (DatabaseThreadList::const_iterator dt_itr = _databaseThreads.begin();
    dt_itr != _databaseThreads.end();
    ++dt_itr)
    {
    (*dt_itr)->startThread();
    }
    }
    }

    #ifdef WITH_REQUESTNODEFILE_TIMING
    totalTime += osg::Timer::instance()->delta_m(start_tick, osg::Timer::instance()->tick());
    #endif
    }

  • 相关阅读:
    较简单的date转化成格式化的timeString
    字符串截取的用法
    UIImageView的图片轮播属性
    label.lineBreakMode设置lable中文字过长时的显示格式,其中可以有末尾以省略号显示。
    UIImageView的contentMode属性
    iOS开发----关于导航条的研究
    iOS 开发对图片进行处理
    设置按钮中的图片的旋转,并且旋转之后不变形
    调整按钮的子控件titleLable和imageView的间距的属性和用法
    ios开发之--数据库开发
  • 原文地址:https://www.cnblogs.com/lyggqm/p/8986075.html
Copyright © 2020-2023  润新知